test(copilot): add JetBrains coverage; fix Windows path inference (#433 follow-up) (#456)
Some checks are pending
CI / semgrep (push) Waiting to run

Follow-up to #433. Adds tests for the JetBrains format: discovery of a jb
chat.jsonl, parsing into a call with inferred model/tools/user message, the
isJetBrainsFormat routing guard (legacy user.message files must not route to
the JB parser), and the id-less dedup fallback.

Also fixes inferJBProjectFromContent: it split homedir() on the platform
separator but the recorded tool path on a fixed '/', so project inference
always fell back to the raw session id on Windows. Split both on either
separator.
This commit is contained in:
Resham Joshi 2026-06-06 23:32:56 +02:00 committed by GitHub
parent e7920fc6aa
commit 0edc8644aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 3 deletions

View file

@ -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'
@ -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)
@ -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) {

View file

@ -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)
})
})