Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/pages/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ export function ChatView() {
<option value="claude-opus-4-7">Opus 4.7</option>
<option value="claude-opus-4-7:max">Opus 4.7 Max</option>
<option value="claude-opus-4-6">Opus 4.6</option>
<option value="claude-opus-4-5">Opus 4.5</option>
<option value="claude-sonnet-4-6">Sonnet 4.6</option>
<option value="claude-sonnet-4-5">Sonnet 4.5</option>
<option value="claude-haiku-4-5">Haiku 4.5</option>
</select>
{!keyboardOpen && (
Expand Down
113 changes: 103 additions & 10 deletions packages/harness/__tests__/auto-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
extractRecentPrompts,
generateSessionName,
generateSessionNameFallback,
sanitizeSessionName,
setClientFactory,
resetClientFactory,
createAnthropicClient,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 67 additions & 9 deletions packages/harness/src/auto-rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -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, '');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: The quote-stripping regex uses a character class that includes both whitespace (\s) and quote chars with + quantifier: ^[\s"'\u201C\u201D\u2018\u2019]+. This means a title like " Spaced Title " correctly produces Spaced Title, but a title that legitimately starts with an apostrophe (e.g. 'Twas) would lose it. Low-risk for session names but worth noting.


// 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.
Expand All @@ -230,16 +287,15 @@ export async function generateSessionName(prompts: string[]): Promise<string> {
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);
}
const response = await client.messages.create(
{
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',
Expand All @@ -251,14 +307,16 @@ export async function generateSessionName(prompts: string[]): Promise<string> {
);

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',
Expand Down
9 changes: 1 addition & 8 deletions packages/harness/src/providers/anthropic-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: The VERTEX_MODEL_MAP removal means callers must now pass Vertex-format model names (e.g. 'claude-haiku-4-5@20251001') directly. If any caller passes standard API names like 'claude-opus-4-6' to a Vertex client, the API call will fail — previously the map translated these. Verify that all call sites that construct AnthropicVertexModelProvider with Vertex enabled pass Vertex-compatible model names.


const response = await this.client.messages.create({
model: apiModel,
Expand Down
21 changes: 6 additions & 15 deletions server/__tests__/auto-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The server test relaxed its assertion from exact argument matching (toHaveBeenCalledWith) to individual field checks (call[0].model, call[0].messages[0].content). This is fine for resilience against prompt changes, but the system prompt (NAMING_SYSTEM_PROMPT) is now completely unverified in the server test suite. Consider adding a minimal assertion like expect(call[0].system).toContain('3-6 word title') so the server tests catch accidental system prompt deletions. [fixable]

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 () => {
Expand Down
2 changes: 2 additions & 0 deletions server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

Expand Down
Loading