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
11 changes: 8 additions & 3 deletions src/providers/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from 'fs'
import { readdir, readFile, stat } from 'fs/promises'
import { basename, dirname, join, posix, sep, win32 } from 'path'
import { basename, dirname, join, posix, win32 } from 'path'
import { homedir } from 'os'

import { readSessionFile } from '../fs-utils.js'
Expand Down Expand Up @@ -617,7 +617,12 @@ async function discoverJetBrainsSessions(jbDir: string): Promise<SessionSource[]

/** Infer a project name from tool execution paths in already-loaded content. */
function inferJBProjectFromContent(content: string): string | null {
const homeParts = homedir().split(sep)
// Split on either separator so the home-depth math lines up with the recorded
// tool path on every platform (JetBrains records Windows paths with
// backslashes, and homedir() also uses backslashes there). Using a fixed '/'
// for the path while splitting home on the platform sep mismatched on Windows
// and made inference always fall back to the raw session id there.
const homeParts = homedir().split(/[/\\]/)
const homeDepth = homeParts.length
const lines = content.split('\n')
const limit = Math.min(lines.length, 200)
Expand All @@ -631,7 +636,7 @@ function inferJBProjectFromContent(content: string): string | null {
const args = e.data?.arguments
if (typeof args === 'object' && args !== null && typeof args.path === 'string') {
const pathVal: string = args.path
const parts = pathVal.split('/')
const parts = pathVal.split(/[/\\]/)
if (parts.length > homeDepth + 1) {
const afterHome = parts.slice(homeDepth)
if (afterHome.length >= 2) {
Expand Down
83 changes: 83 additions & 0 deletions tests/providers/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,86 @@ describe('copilot provider - metadata', () => {
expect(copilot.modelDisplayName('gpt-4.1-mini-2026-01-01')).toBe('GPT-4.1 Mini')
})
})

// JetBrains (IntelliJ/DataGrip) format, added in #433. Discovery + parsing,
// the isJetBrainsFormat routing guard, and the id-less dedup fallback.
describe('copilot provider - JetBrains format', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jb-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})

const jbUser = (text: string) =>
JSON.stringify({ type: 'user.message_rendered', data: { renderedMessage: text } })
const jbTurnStart = (turnId: string) =>
JSON.stringify({ type: 'assistant.turn_start', data: { turnId } })
const jbToolStart = (toolName: string, toolCallId: string, path?: string) =>
JSON.stringify({ type: 'tool.execution_start', data: { toolName, toolCallId, arguments: path ? { path } : {} } })
const jbAssistant = (opts: { messageId?: string; text?: string; outputTokens?: number; iterationNumber?: number }) =>
JSON.stringify({ type: 'assistant.message', data: { ...opts } })

async function writeJbSession(workspaceId: string, lines: string[]) {
const dir = join(tmpDir, workspaceId)
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'chat.jsonl')
await writeFile(filePath, lines.join('\n') + '\n')
return filePath
}

async function parse(filePath: string, seen = new Set<string>()) {
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/vscode', tmpDir)
const source = { path: filePath, project: 'p', provider: 'copilot' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
return calls
}

it('discovers a JetBrains chat.jsonl under the jb dir', async () => {
const filePath = await writeJbSession('ws-abc', [jbUser('hello'), jbAssistant({ messageId: 'm1', text: 'hi', outputTokens: 10 })])
const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/vscode', tmpDir)
const sources = await provider.discoverSessions()
expect(sources.some(s => s.path === filePath && s.provider === 'copilot')).toBe(true)
})

it('parses a JetBrains session into a call with the inferred model and user message', async () => {
const filePath = await writeJbSession('ws-abc', [
jbUser('implement the feature'),
jbTurnStart('t1'),
jbToolStart('read_file', 'toolu_vrtx_x'),
jbAssistant({ messageId: 'm1', text: 'done', outputTokens: 42 }),
])
const calls = await parse(filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.provider).toBe('copilot')
expect(calls[0]!.model).toBe('copilot-anthropic-auto') // toolu_ prefix -> Anthropic
expect(calls[0]!.outputTokens).toBe(42)
expect(calls[0]!.userMessage).toBe('implement the feature')
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[0]!.deduplicationKey.startsWith('copilot:jb:')).toBe(true)
})

it('does NOT route a legacy file (first line user.message) to the JetBrains parser', async () => {
// Regression guard: isJetBrainsFormat must not match bare user.message.
const filePath = await writeJbSession('ws-legacy', [
JSON.stringify({ type: 'user.message', data: { content: 'hi' } }),
JSON.stringify({ type: 'session.model_change', data: { newModel: 'gpt-4.1' } }),
JSON.stringify({ type: 'assistant.message', data: { messageId: 'm1', outputTokens: 5 } }),
])
const calls = await parse(filePath)
// Parsed by the legacy parser -> legacy dedup key, not a jb one.
expect(calls.every(c => !c.deduplicationKey.startsWith('copilot:jb:'))).toBe(true)
})

it('does not collapse id-less assistant messages (dedup fallback)', async () => {
const filePath = await writeJbSession('ws-noid', [
jbUser('q1'),
jbAssistant({ text: 'a1', outputTokens: 5 }),
jbAssistant({ text: 'a2', outputTokens: 6 }),
])
const calls = await parse(filePath)
expect(calls).toHaveLength(2)
expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2)
})
})
Loading