diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 3d191e42..7907e004 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -226,7 +226,9 @@ export function ChatView() { + + {!keyboardOpen && ( diff --git a/packages/harness/__tests__/auto-rename.test.ts b/packages/harness/__tests__/auto-rename.test.ts index d3fa8572..a277124e 100644 --- a/packages/harness/__tests__/auto-rename.test.ts +++ b/packages/harness/__tests__/auto-rename.test.ts @@ -4,6 +4,7 @@ import { extractRecentPrompts, generateSessionName, generateSessionNameFallback, + sanitizeSessionName, setClientFactory, resetClientFactory, createAnthropicClient, @@ -140,16 +141,58 @@ describe('generateSessionName', () => { expect(result).toBe('Auth Bug Fix Session'); expect(mockCreate).toHaveBeenCalledOnce(); - expect(mockCreate).toHaveBeenCalledWith( - { - model: AUTO_RENAME_MODEL, - max_tokens: 20, - system: - 'Generate a 3-6 word title for this chat session. Be specific and descriptive. Return only the title, nothing else.', - messages: [{ role: 'user', content: 'Fix the auth bug\nUpdate login page' }], - }, - { timeout: 5000 }, - ); + const call = mockCreate.mock.calls[0]; + expect(call[0].model).toBe(AUTO_RENAME_MODEL); + expect(call[0].max_tokens).toBe(20); + expect(call[0].messages[0].content).toBe('Fix the auth bug\n---\nUpdate login page'); + expect(call[1]).toEqual({ timeout: 5000 }); + }); + + it('sanitizes Haiku output with embedded quotes', async () => { + const mockCreate = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: '"Fix Auth Module"' }], + }); + + setClientFactory(() => ({ messages: { create: mockCreate } }) as never); + + const result = await generateSessionName(['Fix the auth bug']); + expect(result).toBe('Fix Auth Module'); + }); + + it('falls back when Haiku generates conversational response', async () => { + const mockCreate = vi.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: 'I apologize, but I cannot access the specific Jira ticket list', + }, + ], + }); + + setClientFactory(() => ({ messages: { create: mockCreate } }) as never); + + const prompts = ['check my jira tickets']; + const result = await generateSessionName(prompts); + const fallback = generateSessionNameFallback(prompts); + expect(result).toBe(fallback); + }); + + it('truncates Haiku output at newlines', async () => { + const mockCreate = vi.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: '"OpenShift Agent Integration"\n\nWould you like me to', + }, + ], + }); + + setClientFactory(() => ({ messages: { create: mockCreate } }) as never); + + const result = await generateSessionName(['integrate openshift agent']); + expect(result).toBe('OpenShift Agent Integration'); + expect(result).not.toContain('\n'); + expect(result).not.toContain('Would'); }); it('falls back to keyword extraction when API call fails', async () => { @@ -177,6 +220,56 @@ describe('generateSessionName', () => { }); }); +describe('sanitizeSessionName', () => { + it('passes through clean titles', () => { + expect(sanitizeSessionName('Fix PR Shepherd CI Failures')).toBe('Fix PR Shepherd CI Failures'); + }); + + it('strips surrounding double quotes', () => { + expect(sanitizeSessionName('"Decoupling AI Architecture"')).toBe('Decoupling AI Architecture'); + }); + + it('strips surrounding single quotes', () => { + expect(sanitizeSessionName("'Some Title'")).toBe('Some Title'); + }); + + it('truncates at first newline', () => { + expect(sanitizeSessionName('Good Title\n\nWould you like me to')).toBe('Good Title'); + }); + + it('rejects apology responses', () => { + expect(sanitizeSessionName('I apologize, but I cannot access')).toBe(''); + expect(sanitizeSessionName("I can't determine the topic")).toBe(''); + expect(sanitizeSessionName("I don't have enough context")).toBe(''); + expect(sanitizeSessionName('I am sorry but')).toBe(''); + }); + + it('rejects generic conversation starters', () => { + expect(sanitizeSessionName('Start a Conversation')).toBe(''); + expect(sanitizeSessionName('Start A Conversation')).toBe(''); + expect(sanitizeSessionName('Begin New Chat Session')).toBe(''); + expect(sanitizeSessionName('New Conversation')).toBe(''); + }); + + it('rejects conversational filler', () => { + expect(sanitizeSessionName('Sure, here is the title')).toBe(''); + expect(sanitizeSessionName('Certainly! Fix the bug')).toBe(''); + expect(sanitizeSessionName('Here is the session name')).toBe(''); + }); + + it('enforces max length', () => { + const long = 'A'.repeat(80); + const result = sanitizeSessionName(long); + expect(result.length).toBeLessThanOrEqual(60); + }); + + it('handles combined issues: quotes + newline + trailing text', () => { + expect(sanitizeSessionName('"OpenShift Integration"\n\nWould you like me to elaborate?')).toBe( + 'OpenShift Integration', + ); + }); +}); + describe('AUTO_RENAME_INTERVAL', () => { it('is 2', () => { expect(AUTO_RENAME_INTERVAL).toBe(2); diff --git a/packages/harness/src/auto-rename.ts b/packages/harness/src/auto-rename.ts index c2cd6882..6101ecbc 100644 --- a/packages/harness/src/auto-rename.ts +++ b/packages/harness/src/auto-rename.ts @@ -12,7 +12,7 @@ export const AUTO_RENAME_INTERVAL = 2; export const AUTO_RENAME_MODEL = 'claude-haiku-4-5-20251001'; /** Model name on Vertex AI (uses different naming convention). */ -const VERTEX_MODEL = 'claude-3-5-haiku@20241022'; +const VERTEX_MODEL = 'claude-haiku-4-5@20251001'; /** Max total characters of concatenated prompts sent to the LLM. */ const MAX_PROMPT_INPUT_CHARS = 2000; @@ -23,6 +23,16 @@ const MAX_RECENT_PROMPTS = 8; /** Maximum length of the generated session name. */ const MAX_NAME_LENGTH = 60; +/** Patterns that indicate Haiku went off-script — fall back to keyword extraction. */ +const REJECTED_PATTERNS = [ + /^I\s+(apologize|apologise|cannot|can't|don't|am\s+sorry)/i, + /^(start|begin|new)\s+(a\s+|new\s+)?(conversation|chat|session)/i, + /^(hello|hi|hey|greetings)/i, + /^(sure|certainly|of\s+course)/i, + /^(here|let\s+me)/i, + /^would you like/i, +]; + /** Common stop words to filter out when extracting key terms. */ const STOP_WORDS = new Set([ 'a', @@ -220,6 +230,53 @@ export function generateSessionNameFallback(prompts: string[]): string { return name; } +/** + * Sanitize a Haiku-generated session name. + * Strips quotes, truncates at newlines, and rejects conversational responses. + * Returns empty string if the name is rejected. + */ +export function sanitizeSessionName(raw: string): string { + // Truncate at first newline — Haiku sometimes generates multi-line output + let name = raw.split('\n')[0].trim(); + + // Strip surrounding quotes (single, double, smart quotes) + name = name.replace(/^[\s"'\u201C\u201D\u2018\u2019]+|[\s"'\u201C\u201D\u2018\u2019]+$/g, ''); + + // Reject conversational responses — Haiku went off-script + for (const pattern of REJECTED_PATTERNS) { + if (pattern.test(name)) { + log.warn('rejected Haiku title (conversational)', { raw: raw.slice(0, 80) }); + return ''; + } + } + + // Enforce length limit + if (name.length > MAX_NAME_LENGTH) { + name = name.slice(0, MAX_NAME_LENGTH).trim(); + } + + return name; +} + +/** System prompt for LLM-based session naming. */ +const NAMING_SYSTEM_PROMPT = `Extract a 3-6 word title from the user messages below. The title should capture the primary topic or goal. + +Rules: +- Output ONLY the title, no quotes, no explanation, no punctuation except hyphens +- Be specific: "Fix PR Shepherd CI Failures" not "Fix Bug" +- Never apologize, ask questions, or add commentary +- If messages discuss multiple topics, title the dominant one + +Examples: +Messages: "can you check why the tests are failing on the pr shepherd branch" +Title: Debug PR Shepherd Test Failures + +Messages: "I need to update the morning briefing to include slack data" +Title: Add Slack Data to Briefing + +Messages: "what's the status of my open PRs across all repos" +Title: Cross-Repo PR Status Check`; + /** * Generate a short session name using Claude Haiku. * Falls back to keyword extraction if the API call fails. @@ -230,7 +287,7 @@ export async function generateSessionName(prompts: string[]): Promise { try { const client = clientFactory(); const isVertex = client instanceof AnthropicVertex; - let input = prompts.join('\n'); + let input = prompts.join('\n---\n'); if (input.length > MAX_PROMPT_INPUT_CHARS) { input = input.slice(0, MAX_PROMPT_INPUT_CHARS); } @@ -238,8 +295,7 @@ export async function generateSessionName(prompts: string[]): Promise { { model: isVertex ? VERTEX_MODEL : AUTO_RENAME_MODEL, max_tokens: 20, - system: - 'What is the user trying to accomplish? Generate a 3-6 word title capturing their intent or goal. Be action-oriented and specific. Return only the title, nothing else.', + system: NAMING_SYSTEM_PROMPT, messages: [ { role: 'user', @@ -251,14 +307,16 @@ export async function generateSessionName(prompts: string[]): Promise { ); const textBlock = response.content.find((b) => b.type === 'text'); - const name = textBlock?.text?.trim() ?? ''; + const rawName = textBlock?.text?.trim() ?? ''; + const name = sanitizeSessionName(rawName); - if (name && name.length <= MAX_NAME_LENGTH) { - return name; - } if (name) { - return name.slice(0, MAX_NAME_LENGTH).trim(); + return name; } + + log.info('Haiku returned empty or rejected title, using fallback', { + raw: rawName.slice(0, 80), + }); } catch (err: unknown) { log.warn('Haiku auto-rename failed, falling back to keyword extraction', { error: err instanceof Error ? err.message : 'unknown', diff --git a/packages/harness/src/providers/anthropic-vertex.ts b/packages/harness/src/providers/anthropic-vertex.ts index 17066abe..2911cd12 100644 --- a/packages/harness/src/providers/anthropic-vertex.ts +++ b/packages/harness/src/providers/anthropic-vertex.ts @@ -13,13 +13,6 @@ import { calculateCost } from './types.js'; const log = createLogger('provider:anthropic'); -/** Vertex AI model name mapping (Vertex uses different naming). */ -const VERTEX_MODEL_MAP: Record = { - 'claude-opus-4-6': 'claude-opus-4-6@20250514', - 'claude-sonnet-4-6': 'claude-sonnet-4-6@20250514', - 'claude-haiku-4-5': 'claude-3-5-haiku@20241022', -}; - export interface AnthropicVertexProviderOptions { /** GCP project ID. Falls back to ANTHROPIC_VERTEX_PROJECT_ID env var. */ projectId?: string; @@ -58,7 +51,7 @@ export class AnthropicVertexModelProvider implements ModelProvider { const systemMessages = messages.filter((m) => m.role === 'system'); const conversationMessages = messages.filter((m) => m.role !== 'system'); - const apiModel = this.isVertex ? (VERTEX_MODEL_MAP[this.model] ?? this.model) : this.model; + const apiModel = this.model; const response = await this.client.messages.create({ model: apiModel, diff --git a/server/__tests__/auto-rename.test.ts b/server/__tests__/auto-rename.test.ts index d47c7b34..03a09272 100644 --- a/server/__tests__/auto-rename.test.ts +++ b/server/__tests__/auto-rename.test.ts @@ -156,21 +156,12 @@ describe('generateSessionName', () => { expect(result).toBe('Auth Bug Fix Session'); expect(mockCreate).toHaveBeenCalledOnce(); - expect(mockCreate).toHaveBeenCalledWith( - { - model: AUTO_RENAME_MODEL, - max_tokens: 20, - system: - 'What is the user trying to accomplish? Generate a 3-6 word title capturing their intent or goal. Be action-oriented and specific. Return only the title, nothing else.', - messages: [ - { - role: 'user', - content: 'Fix the auth bug\nUpdate login page', - }, - ], - }, - { timeout: 5000 }, - ); + const call = mockCreate.mock.calls[0]; + expect(call[0].model).toBe(AUTO_RENAME_MODEL); + expect(call[0].max_tokens).toBe(20); + expect(call[0].messages[0].content).toContain('Fix the auth bug'); + expect(call[0].messages[0].content).toContain('Update login page'); + expect(call[1]).toEqual({ timeout: 5000 }); }); it('falls back to keyword extraction when API call fails', async () => { diff --git a/server/chat.ts b/server/chat.ts index 50579b77..6442db70 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -369,7 +369,9 @@ export const AVAILABLE_MODELS = [ { id: 'claude-opus-4-7', label: 'Opus 4.7', desc: 'Adaptive thinking' }, { id: 'claude-opus-4-7:max', label: 'Opus 4.7 Max', desc: 'Max thinking (128k)' }, { id: 'claude-opus-4-6', label: 'Opus 4.6', desc: 'Previous Opus' }, + { id: 'claude-opus-4-5', label: 'Opus 4.5', desc: 'Legacy Opus' }, { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: 'Balanced' }, + { id: 'claude-sonnet-4-5', label: 'Sonnet 4.5', desc: 'Previous Sonnet' }, { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest' }, ];