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
29 changes: 11 additions & 18 deletions server/coding-cli/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getClaudeHome } from '../../claude-home.js'
import type { CodingCliProvider } from '../provider.js'
import { normalizeFirstUserMessage, type NormalizedEvent, type ParsedSessionMeta, type TokenSummary } from '../types.js'
import { parseClaudeEvent, isMessageEvent, isResultEvent, isToolResultContent, isToolUseContent, isTextContent } from '../../claude-stream-types.js'
import { looksLikePath, isSystemContext, extractFromIdeContext, resolveGitRepoRoot } from '../utils.js'
import { looksLikePath, extractUserAuthoredText, resolveGitRepoRoot } from '../utils.js'

export type JsonlMeta = {
sessionId?: string
Expand Down Expand Up @@ -376,7 +376,8 @@ export function parseSessionContent(content: string, options: ParseSessionOption
if (typeof modelCandidate === 'string') model = modelCandidate
}
const userMessageText = extractUserMessageText(obj)
if (userMessageText !== undefined && !isSystemContext(userMessageText)) {
const userAuthoredText = typeof userMessageText === 'string' ? extractUserAuthoredText(userMessageText) : undefined
if (userAuthoredText !== undefined) {
userMessageCount++
}

Expand All @@ -393,20 +394,12 @@ export function parseSessionContent(content: string, options: ParseSessionOption
}

if (!title) {
const t =
obj?.title ||
obj?.sessionTitle ||
userMessageText

if (typeof t === 'string' && t.trim()) {
// Try to extract user request from IDE-formatted context first
const ideRequest = extractFromIdeContext(t)
if (ideRequest) {
title = extractTitleFromMessage(ideRequest, 200)
} else if (!isSystemContext(t)) {
// Store up to 200 chars - UI truncates visually, tooltip shows full text
title = extractTitleFromMessage(t, 200)
}
const explicitTitle = obj?.title || obj?.sessionTitle
if (typeof explicitTitle === 'string' && explicitTitle.trim()) {
title = extractTitleFromMessage(explicitTitle, 200)
} else if (userAuthoredText) {
// Store up to 200 chars - UI truncates visually, tooltip shows full text
title = extractTitleFromMessage(userAuthoredText, 200)
}
}

Expand All @@ -418,8 +411,8 @@ export function parseSessionContent(content: string, options: ParseSessionOption
}

if (!firstUserMessage) {
if (typeof userMessageText === 'string') {
const normalized = normalizeFirstUserMessage(userMessageText)
if (userAuthoredText) {
const normalized = normalizeFirstUserMessage(userAuthoredText)
if (normalized) firstUserMessage = normalized
}
}
Expand Down
19 changes: 5 additions & 14 deletions server/coding-cli/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fsp from 'fs/promises'
import { extractTitleFromMessage } from '../../title-utils.js'
import type { CodingCliProvider } from '../provider.js'
import { normalizeFirstUserMessage, type CodexTaskEventSnapshot, type NormalizedEvent, type ParsedSessionMeta, type TokenPayload, type TokenSummary } from '../types.js'
import { looksLikePath, isSystemContext, extractFromIdeContext, resolveGitRepoRoot } from '../utils.js'
import { looksLikePath, extractUserAuthoredText, resolveGitRepoRoot } from '../utils.js'

const CODEX_MAX_PLAUSIBLE_CONTEXT_TOKENS_WITHOUT_WINDOW = 5_000_000
// Codex `model_context_window` is reduced by `effective_context_window_percent` (default 95%).
Expand Down Expand Up @@ -318,22 +318,13 @@ export function parseCodexSessionContent(content: string): ParsedSessionMeta {

if (obj?.type === 'response_item' && obj?.payload?.type === 'message' && obj?.payload?.role === 'user') {
const text = extractTextContent(obj.payload.content)
const normalized = normalizeFirstUserMessage(text)
const userText = extractUserAuthoredText(text)
const normalized = userText ? normalizeFirstUserMessage(userText) : undefined
if (!firstUserMessage && normalized) {
firstUserMessage = normalized
}
if (!title && text.trim()) {
// Try to extract user request from IDE-formatted context first
const ideRequest = extractFromIdeContext(text)
if (ideRequest) {
title = extractTitleFromMessage(ideRequest, 200)
} else if (!isSystemContext(text)) {
// Strip image markup tags so titles show the actual user request
const cleaned = text.replace(/<\/?image[^>]*>/g, '').trim()
if (cleaned) {
title = extractTitleFromMessage(cleaned, 200)
}
}
if (!title && userText) {
title = extractTitleFromMessage(userText, 200)
}
}

Expand Down
6 changes: 2 additions & 4 deletions server/coding-cli/session-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { extractTitleFromMessage } from '../title-utils.js'
import type { CodingCliProvider } from './provider.js'
import { makeSessionKey, type CodingCliSession, type CodingCliProviderName, type ProjectGroup } from './types.js'
import { sanitizeCodexTaskEventsForTruncatedSnippet } from './providers/codex.js'
import { extractFromIdeContext, isSystemContext, resolveGitCheckoutRoot, resolveGitRepoRoot } from './utils.js'
import { extractUserAuthoredText, resolveGitCheckoutRoot, resolveGitRepoRoot } from './utils.js'
import { diffProjects } from '../sessions-sync/diff.js'
import type { SessionMetadataStore, SessionMetadataEntry } from '../session-metadata-store.js'

Expand Down Expand Up @@ -297,9 +297,7 @@ async function readLightweightMeta(filePath: string): Promise<LightweightFileMet
.join('\n')
: undefined

const ideRequest = rawText ? extractFromIdeContext(rawText) : undefined
const candidate = ideRequest
|| (!isSystemContext(rawText ?? '') ? rawText?.replace(/<\/?image[^>]*>/g, '').trim() : '')
const candidate = rawText ? extractUserAuthoredText(rawText) : undefined
if (candidate) {
title = extractTitleFromMessage(candidate, 200)
}
Expand Down
65 changes: 65 additions & 0 deletions server/coding-cli/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,22 @@ export function isSystemContext(text: string): boolean {
return false
}

const USER_CONTEXT_TAGS = new Set([
'environment_context',
'system_context',
'system',
'context',
'instructions',
'user_instructions',
'permissions',
'collaboration_mode',
'skills_instructions',
])

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
* Extract the actual user request from IDE-formatted context messages.
* IDE context messages follow this format:
Expand Down Expand Up @@ -362,3 +378,52 @@ export function extractFromIdeContext(text: string): string | undefined {

return undefined
}

/**
* Returns only text authored as the user's task/request, excluding context that
* coding CLIs serialize as role:"user" records.
*/
export function extractUserAuthoredText(text: string): string | undefined {
const trimmed = text.trim()
if (!trimmed) return undefined

const ideRequest = extractFromIdeContext(trimmed)
if (ideRequest) return ideRequest
Comment on lines +390 to +391
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve full IDE request text for firstUserMessage

When an IDE-context record contains a multi-line ## My request for Codex: section, this returns extractFromIdeContext(...), which only returns the first non-empty line of that section. The same userAuthoredText is now used to populate firstUserMessage in both Claude and Codex providers, so sessions with requests like Implement X\n- handle A\n- handle B lose everything after the first line; that truncated value is later used for AI title generation, session search, and first-chat exclusion filters. Title extraction can still use the first line, but the canonical first user message should keep the full request body.

Useful? React with 👍 / 👎.


if (!isSystemContext(trimmed)) {
const cleaned = trimmed.replace(/<\/?image[^>]*>/g, '').trim()
return cleaned || undefined
}

let rest = trimmed
let removedStructuredBlock = false
for (;;) {
const before = rest
rest = rest.trim()

const agentsHeader = rest.match(/^#\s*AGENTS(?:\.md)? instructions[^\n]*(?:\n|$)/i)
if (agentsHeader) {
rest = rest.slice(agentsHeader[0].length)
continue
}

const xmlOpen = rest.match(/^<([a-zA-Z_][\w-]*)\b[^>]*>/)
if (xmlOpen) {
const tag = xmlOpen[1].toLowerCase()
if (!USER_CONTEXT_TAGS.has(tag)) return undefined
const closePattern = new RegExp(`</${escapeRegExp(xmlOpen[1])}>`, 'i')
const close = closePattern.exec(rest)
if (!close) return undefined
rest = rest.slice((close.index ?? 0) + close[0].length)
removedStructuredBlock = true
continue
}

if (rest === before) break
}

if (!removedStructuredBlock) return undefined

const cleaned = rest.replace(/<\/?image[^>]*>/g, '').trim()
return cleaned || undefined
}
2 changes: 2 additions & 0 deletions test/unit/server/coding-cli/claude-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ describe('claude provider cross-platform tests', () => {
const meta = parseSessionContent(content)

expect(meta.title).toBe('Build the feature')
expect(meta.firstUserMessage).toBe('Build the feature')
})

it('skips XML-wrapped system context', () => {
Expand Down Expand Up @@ -859,6 +860,7 @@ describe('claude provider cross-platform tests', () => {
const meta = parseSessionContent(content)

expect(meta.title).toBeUndefined()
expect(meta.firstUserMessage).toBeUndefined()
})

it('extracts user request from IDE context messages', () => {
Expand Down
34 changes: 34 additions & 0 deletions test/unit/server/coding-cli/codex-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ describe('codex-provider', () => {
const meta = parseCodexSessionContent(content)

expect(meta.title).toBe('Review the current code changes')
expect(meta.firstUserMessage).toBe('Review the current code changes')
})

it('skips messages starting with XML tags like <INSTRUCTIONS>', () => {
Expand Down Expand Up @@ -897,6 +898,39 @@ describe('codex-provider', () => {
const meta = parseCodexSessionContent(content)

expect(meta.title).toBeUndefined()
expect(meta.firstUserMessage).toBeUndefined()
})

it('extracts firstUserMessage from the request inside IDE context', () => {
const ideMessage = [
'# Context from my IDE setup:',
'',
'## My codebase',
'This is a React project...',
'',
'## My request for Codex:',
'Fix the authentication bug in the login form',
].join('\n')

const content = [
JSON.stringify({
type: 'session_meta',
payload: { id: 'session-ide-first-user', cwd: '/project' },
}),
JSON.stringify({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: ideMessage }],
},
}),
].join('\n')

const meta = parseCodexSessionContent(content)

expect(meta.title).toBe('Fix the authentication bug in the login form')
expect(meta.firstUserMessage).toBe('Fix the authentication bug in the login form')
})

it('extracts user request from IDE context messages', () => {
Expand Down
22 changes: 21 additions & 1 deletion test/unit/server/coding-cli/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { isSystemContext, extractFromIdeContext } from '../../../../server/coding-cli/utils'
import { isSystemContext, extractFromIdeContext, extractUserAuthoredText } from '../../../../server/coding-cli/utils'

describe('isSystemContext()', () => {
describe('XML-wrapped context', () => {
Expand Down Expand Up @@ -200,3 +200,23 @@ describe('extractFromIdeContext()', () => {
expect(extractFromIdeContext(text)).toBeUndefined()
})
})

describe('extractUserAuthoredText()', () => {
it('skips leading AGENTS instructions and returns trailing user request', () => {
const text = [
'# AGENTS.md instructions for /project',
'',
'<INSTRUCTIONS>',
'Prefer bash to powershell.',
'</INSTRUCTIONS>',
'',
'Find, root cause, investigate, etc.',
].join('\n')

expect(extractUserAuthoredText(text)).toBe('Find, root cause, investigate, etc.')
})

it('does not treat plain AGENTS instruction text as a user request', () => {
expect(extractUserAuthoredText('# AGENTS.md instructions\n\nFollow these rules...')).toBeUndefined()
})
})
Loading