fix(copilot): parse JetBrains agent sessions from old plugin format (≤1.5.x)

JetBrains Copilot plugin ≤1.5.x (e.g. 1.5.59-243) stores all session turns
inside ONE large binary-framed outer Nitrite document, rather than the
per-turn {"__first__":{"type":"Subgraph",...}} blobs introduced in later
plugins (≥1.12.x, e.g. 1.12.1-251).

In the old format each assistant turn is a UUID-keyed Value entry whose
value field contains a JSON-string-escaped AgentRound record:

  {"<uuid>":{"type":"Value","value":"{\"type\":\"AgentRound\",
    \"data\":\"{...reply...}\"}"}, ...}

The extractResponseText depth-unescape loop already handles this one extra
level of escaping; the only gap was that extractJetBrainsDbTurns never fed
it the outer document — it only scanned for __first__/Subgraph blobs, which
the old plugin never writes.

Add a fallback that activates when the Subgraph scan produces zero turns but
'AgentRound' text is present in the raw file (old-format signal). It locates
the binary-framed outer document (UUID-keyed Value entry, hex matched
case-insensitively so an uppercase UUID does not fall through to $0), extracts
it with matchJsonObject, and passes it to extractResponseText. Because the outer
document holds every turn in one blob, this emits ONE session-level call per
document (all rounds' replies joined): cost/tokens are correct, only the
per-turn call-count granularity is coarser — an accepted tradeoff for legacy
data. MVStore keeps two identical collection copies; seenReplies dedupes them.

The fallback is guarded by turns.length === 0 so new-format sessions (whose
Subgraph scan succeeds) are completely unaffected and never double-counted.

Tests: old-format doc with multiple AgentRound rounds → 1 call whose token
count equals the two non-empty replies joined (the empty tool-call round is
excluded); an uppercase-UUID variant (fails without the case-insensitive
match); and a guard that new-format Subgraph turns are not double-counted.
docs/providers/copilot.md documents the old format and the one-call-per-session
limitation.
This commit is contained in:
Nihal Jain 2026-07-03 18:04:24 +05:30
parent 2916cd988e
commit cd07707363
3 changed files with 181 additions and 0 deletions

View file

@ -92,6 +92,18 @@ Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-though
**not** billable assistant output and are deliberately skipped. User prompts are
the simpler `{"<uuid>":{"type":"Value",…}}` value-maps.
**Old plugin format (≤1.5.x, e.g. 1.5.59-243).** Older plugins do not write
per-turn `__first__`/Subgraph blobs at all — they store the whole session as ONE
binary-framed outer Nitrite document of UUID-keyed `Value` entries, with the
`AgentRound` records one escaping level deeper. When the Subgraph scan finds no
turns but the raw file contains `AgentRound` text, a fallback locates that outer
document (`extractJetBrainsDbTurns`), runs it through the same
`extractResponseText` depth-unescape, and emits **one session-level call** per
document (all rounds' replies joined). Cost and tokens are correct; only the
per-turn call-count granularity is coarser than the new format — an accepted
tradeoff for legacy data. The fallback is gated on the new-format scan yielding
nothing, so current sessions are never affected or double-counted.
(Store dirs may also contain a legacy `00000000000.xd` Xodus log from older
plugin versions. On every installation observed it is either empty or shadowed
by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever

View file

@ -1301,6 +1301,78 @@ function extractJetBrainsDbTurns(raw: string): JBDbTurn[] {
turns.push({ replyText, model, errored: false, conversationId, conversationTitle, conversationProject })
}
// ---------------------------------------------------------------------------
// Fallback: old JetBrains Copilot plugin format (≤1.5.x, e.g. 1.5.59-243)
// ---------------------------------------------------------------------------
// In this format ALL session turns are stored inside ONE large outer Nitrite
// document — a binary-framed JSON object with UUID-keyed Value entries — rather
// than the per-turn {"__first__":{"type":"Subgraph",...}} blobs used by newer
// plugins (≥1.12.x). The AgentRound entries sit one escaping level deeper
// inside the outer document's string values, so `extractResponseText`'s
// depth-unescape loop handles extraction correctly once we feed it the right
// chunk. MVStore keeps two identical copies of the collection; `seenReplies`
// deduplicates them automatically.
//
// Detection heuristic: the __first__/Subgraph path produced no turns AND the
// raw file contains bare 'AgentRound' text (meaning old-format data is present).
if (turns.length === 0 && raw.includes('AgentRound')) {
// The outer Nitrite document is preceded by a single binary framing byte
// (0x81 in practice, but any non-printable/non-ASCII byte in MVStore).
// It starts with a UUID-keyed Value entry: {"<uuid>":{"type":"Value",...}}.
// Hex is matched case-insensitively — an uppercase UUID must not cause the
// whole session to fall through to $0 (the exact bug this path fixes).
const outerDocRe = /[\x00-\x1f\x7f-\xff]\{"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}":\{"type":"Value"/g
let dm: RegExpExecArray | null
while ((dm = outerDocRe.exec(raw))) {
// Skip the leading binary byte; matchJsonObject starts at the '{'.
const docStart = dm.index + 1
const { chunk, end } = matchJsonObject(raw, docStart)
outerDocRe.lastIndex = end
// Skip documents that contain no AgentRound data (e.g. empty sessions).
if (!chunk.includes('AgentRound')) continue
// Attribute to the conversation whose GUID most recently precedes this doc.
let conversationId = ''
let conversationTitle = ''
let bestPos = -1
for (const c of convById.values()) {
const p = raw.lastIndexOf(c.id, docStart)
if (p > bestPos) {
bestPos = p
conversationId = c.id
conversationTitle = c.title
}
}
// extractResponseText handles the depth-1 unescape needed to surface the
// AgentRound records, then calls extractAgentRoundReplies for each turn.
// Because the outer document holds ALL turns in one blob we get back a
// single joined string; split it on the '\n' join to yield per-turn texts.
const allReplies = extractResponseText(chunk)
if (!allReplies) continue
const conversationProject = inferJetBrainsProject(chunk) ?? ''
const storeModel = findJetBrainsModelToken(chunk)
// extractResponseText joins multiple replies with '\n'. Since individual
// replies can themselves span multiple lines we cannot cleanly split here —
// instead we emit one ParsedProviderCall per outer document (one session).
const dedupeKey = `${conversationId}::${allReplies}`
if (seenReplies.has(dedupeKey)) continue
seenReplies.add(dedupeKey)
turns.push({
replyText: allReplies,
model: storeModel,
errored: false,
conversationId,
conversationTitle,
conversationProject,
})
}
}
// A project derived from ANY turn of a conversation applies to all its turns
// (the files are usually referenced in the first substantive turn only).
const projByConv = new Map<string, string>()

View file

@ -1657,4 +1657,101 @@ describe('copilot provider - JetBrains parsing', () => {
const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-utf8name')!
expect((src as { projectName?: string }).projectName).toBe(name)
})
// ---------------------------------------------------------------------------
// Old plugin format (≤1.5.x, e.g. 1.5.59-243)
// ---------------------------------------------------------------------------
// In the old plugin all session turns live inside ONE large binary-framed
// outer Nitrite document. Each turn's response is stored as a UUID-keyed
// Value entry containing an AgentRound record (one escaping level deeper than
// the __first__/Subgraph format used by plugins ≥1.12.x).
/**
* Build an outer Nitrite document in the old plugin format.
* The document is preceded by a single binary byte (0x81) and starts with a
* UUID-keyed Value entry. Each AgentRound is stored as a Value whose value
* field is a JSON string containing {\"type\":\"AgentRound\",\"data\":\"...\"}
* (one level of JSON-string escaping from the document root).
*/
function jbOldFormatDoc(rounds: Array<{ reply: string; model?: string }>, opts: { upperUuid?: boolean } = {}) {
const cased = (u: string) => (opts.upperUuid ? u.toUpperCase() : u)
const entries: Record<string, unknown> = {}
// Lead entry (mimics the References record always present in real DBs)
entries[cased('0f383f5c-f169-4fee-9115-c06d4dd8985f')] = {
type: 'Value',
value: JSON.stringify({ type: 'References', data: '[]' }),
}
rounds.forEach((r, i) => {
const uuid = cased(`ccadf30b-fa34-4387-9f14-0a5f63457d${String(i).padStart(2, '0')}`)
const agentRoundData = JSON.stringify({ roundId: i + 1, reply: r.reply, toolCalls: [] })
const agentRoundValue = JSON.stringify({ type: 'AgentRound', data: agentRoundData })
entries[uuid] = { type: 'Value', value: agentRoundValue }
if (r.model) {
const modelUuid = cased(`bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb${String(i).padStart(4, '0')}`)
entries[modelUuid] = { type: 'Value', value: `{"model":"${r.model}"}` }
}
})
// Binary framing byte (0x81) followed by the JSON document
return '\x81' + JSON.stringify(entries)
}
it('parses agent turns from old plugin format (≤1.5.x, no __first__ blobs)', async () => {
// The old plugin stores all turns in one big outer Nitrite document with a
// binary framing byte. The fallback path must find and parse it.
const convGuid = '17a5d71b-27f7-4937-8803-7fc2cbb705cb'
const convRecord = jbConversationRecord(convGuid, 'Understanding HBase Architecture')
const oldFormatContent =
'H:2,block:8,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
convRecord + '\n' +
jbOldFormatDoc([
{ reply: "I'll scan the repository to find the top-level project structure.", model: 'gpt-4.1' },
{ reply: "Now I'll open the README to explain architecture." },
{ reply: '' }, // empty reply (pure tool-call round) — must not produce a call
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-1', oldFormatContent)
const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-1'))
// The fallback emits one call per outer document (all replies joined).
expect(calls).toHaveLength(1)
expect(calls[0]!.costIsEstimated).toBe(true)
// The two NON-EMPTY rounds are captured and joined; the empty (tool-call)
// round contributes nothing. Assert the exact combined token count so the
// test fails if either reply is dropped or the empty round leaks in.
const joined =
"I'll scan the repository to find the top-level project structure.\n" +
"Now I'll open the README to explain architecture."
expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4))
// The session label is the conversation TITLE, not the reply text.
expect(calls[0]!.userMessage).toBe('Understanding HBase Architecture')
})
it('parses old plugin format when the outer-doc UUIDs are uppercase hex', async () => {
// The outer-doc detection must be case-insensitive: an uppercase UUID must
// not make the whole session fall through to $0.
const convRecord = jbConversationRecord('27b6e82c-38f8-4048-9914-8fd3dcc816dc', 'Conv Upper')
const content =
'H:2,block:8,blockSize:1000,format:3\n' +
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
convRecord + '\n' +
jbOldFormatDoc([{ reply: 'An uppercase-UUID reply with enough words to score.' }], { upperUuid: true })
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-upper', content)
const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-upper'))
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
it('old plugin format: does not parse when __first__ blobs already yield turns (no double-count)', async () => {
// When the newer __first__/Subgraph path finds turns, the old-format fallback
// must not run (turns.length > 0 prevents it).
const content = jbDbContent([
jbAgentBlob(['A reply from the new format.']),
])
const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'new-fmt-guard', content)
const calls = await collectCalls(jbDbSource(dbPath, 'new-fmt-guard'))
// Only the one Subgraph-format turn — no old-format duplicates
expect(calls).toHaveLength(1)
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
})