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' },
];