diff --git a/src/handlers/chat.js b/src/handlers/chat.js index d02707ad..76aa9580 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -3032,7 +3032,7 @@ async function _handleChatCompletionsInner(body, context = {}) { const connectSessionId = resolveConnectSessionId(callerKey || '', connectMessages); if (connectSessionId) { connectParams.sessionId = connectSessionId; - log.info(`Chat[${reqId}]: DEVIN_CONNECT session reuse active → session_id=${connectSessionId}`); + log.info(`Chat[${reqId}]: DEVIN_CONNECT session reuse active → session_id=${connectSessionId} acct=${ccAcct?.account?.id || 'env-token'}`); } if (ccAcct) { connectParams.token = ccAcct.apiKey; diff --git a/src/session-continuity.js b/src/session-continuity.js index 96914f46..c86d6d44 100644 --- a/src/session-continuity.js +++ b/src/session-continuity.js @@ -279,8 +279,11 @@ export function buildPairHashes(callerKey, messages) { // ─── Resolver ────────────────────────────────────────────────────────────── /** - * Overlap score: how many of the candidate's hashes appear as a contiguous - * subsequence within incoming (anchored at the last stored hash). + * Overlap score: the contiguous run of candidate-window hashes ending at the + * candidate's LAST hash (a retained tail is always a SUFFIX of the committed + * chain — compaction cuts the head, never the tail). Anchoring at the tail is + * what keeps a divergent dialog that merely shares an early pair from + * hijacking the session: a prefix-only run scores 0 here. */ function overlapScore(incoming, candidateWindow) { if (!candidateWindow.length || !incoming.length) return 0; @@ -471,6 +474,40 @@ export function resolveSessionId(callerKey, messages, env = process.env) { return best.sessionId; } + // Root fallback (compaction survival). Measured on a real kimi compaction: + // 0 of 31 retained pairs survive byte-for-byte or canonically — the client + // rewrites the retained tail — BUT the dialog's first input turn survives + // verbatim. States are indexed by that rootKey at creation, so re-associate + // through it when pair evidence is gone. Several live candidates with no pair + // evidence = ambiguous -> assign to none (collision rule), let a new id form. + // Guard: only when NO incoming hash matched any stored index (seen empty) — a + // divergent dialog whose prefix pair still hits the index must NOT be + // re-associated through the root (pair evidence exists; it just scores 0). + if (seen.size === 0) { + const rootKey = rootAnchorKey(scopeId, analysis); + if (rootKey) { + const rootSet = pairIndex.get(rootKey); + if (rootSet) { + const live = []; + for (const stateId of rootSet) { + const state = statesById.get(stateId); + if (!state) continue; + if (now - state.lastSeen > ttl) { evictState(stateId); continue; } + live.push(state); + } + if (live.length === 1) { + const state = live[0]; + state.lastSeen = now; + const newWindow = hashes.slice(-PAIR_WINDOW_SIZE); + state.pairWindow = newWindow; + indexState(state.stateId, state); + state.pairRecords = postBarrierRecords.slice(-PAIR_WINDOW_SIZE); + return state.sessionId; + } + } + } + } + // No match — create new state clearExpired(env); enforceCapacity(env); @@ -575,10 +612,19 @@ export function commitAfterResponse(callerKey, messagesWithResponse, env = proce const sessionId = crypto.randomUUID(); const stateId = crypto.randomUUID(); const dialogAnchor = pairWindow[0]?.slice(0, 16) || null; - const state = { stateId, scopeId, sessionId, pairWindow, pairRecords: postBarrierRecords.slice(-PAIR_WINDOW_SIZE), lastSeen: now, commitKey, dialogAnchor }; + // Root-index the fork too: a forked dialog shares its opener's root anchor, so + // a later compacted resolve must see it as a SECOND live root candidate — + // otherwise the root fallback would re-associate the fork into the ORIGINAL + // session (hijack). The fallback's ambiguity rule (several live -> assign to + // none) only works when every root-sharing state is visible under the anchor. + const state = { stateId, scopeId, sessionId, pairWindow, pairRecords: postBarrierRecords.slice(-PAIR_WINDOW_SIZE), lastSeen: now, commitKey, dialogAnchor, rootKey }; statesById.set(stateId, state); commitIndex.set(commitKey, stateId); indexState(stateId, state); + if (rootKey) { + if (!pairIndex.has(rootKey)) pairIndex.set(rootKey, new Set()); + pairIndex.get(rootKey).add(stateId); + } return sessionId; } diff --git a/test/chat-reuse-log.test.js b/test/chat-reuse-log.test.js new file mode 100644 index 00000000..a9b85085 --- /dev/null +++ b/test/chat-reuse-log.test.js @@ -0,0 +1,41 @@ +// The DEVIN_CONNECT session-reuse log line must identify WHICH account served +// the resumed session (acct=), not just the session_id. This locks +// the log-line construction itself: the acct= tag lives in the same template +// literal as session_id=, and ccAcct is in scope at the call site (it is read +// immediately below to set connectParams.token). + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CHAT = readFileSync(join(__dirname, '..', 'src', 'handlers', 'chat.js'), 'utf8'); + +const REUSE_LOG = 'DEVIN_CONNECT session reuse active'; + +describe('chat handler: DEVIN_CONNECT reuse log carries the account', () => { + it('the reuse log line appends acct= to the session_id interpolation', () => { + const idx = CHAT.indexOf(REUSE_LOG); + assert.ok(idx !== -1, 'the DEVIN_CONNECT reuse log line must exist'); + const line = CHAT.slice(idx, CHAT.indexOf('\n', idx)); + assert.match(line, /session_id=\$\{connectSessionId\}/, 'the log must carry session_id'); + assert.match( + line, + /acct=\$\{ccAcct\?\.account\?\.id \|\| 'env-token'\}/, + 'the log must append acct= (env-token fallback when no account is bound)', + ); + }); + + it('ccAcct is in scope at the reuse log call site (read right below for connectParams.token)', () => { + const idx = CHAT.indexOf(REUSE_LOG); + assert.ok(idx !== -1, 'the DEVIN_CONNECT reuse log line must exist'); + const after = CHAT.slice(idx, idx + 400); + assert.match( + after, + /if \(ccAcct\) \{[\s\S]*connectParams\.token = ccAcct\.apiKey;/, + 'the ccAcct guard for connectParams.token must immediately follow the reuse log call', + ); + }); +}); diff --git a/test/mutations/session-continuity-compaction-survival.json b/test/mutations/session-continuity-compaction-survival.json new file mode 100644 index 00000000..e0c6261c --- /dev/null +++ b/test/mutations/session-continuity-compaction-survival.json @@ -0,0 +1,27 @@ +{ + "tests": [ + "test/session-continuity.test.js", + "test/chat-reuse-log.test.js" + ], + "expectBaselinePass": 35, + "mutations": [ + { + "name": "root fallback fires even when pair evidence exists — a live session gets re-associated through the root", + "file": "src/session-continuity.js", + "anchor": " if (seen.size === 0) {\n const rootKey = rootAnchorKey(scopeId, analysis);", + "replacement": " if (true) {\n const rootKey = rootAnchorKey(scopeId, analysis);" + }, + { + "name": "ambiguity rule dropped — two live states on one root: the first one wins instead of a refused re-association", + "file": "src/session-continuity.js", + "anchor": " if (live.length === 1) {\n const state = live[0];", + "replacement": " if (live.length >= 1) {\n const state = live[0];" + }, + { + "name": "TTL eviction skipped in the root fallback — an evicted state resurrects through compaction survival", + "file": "src/session-continuity.js", + "anchor": " if (now - state.lastSeen > ttl) { evictState(stateId); continue; }", + "replacement": " if (false) { evictState(stateId); continue; }" + } + ] +} diff --git a/test/session-continuity.test.js b/test/session-continuity.test.js index 3e9b3d4c..75fb7ee5 100644 --- a/test/session-continuity.test.js +++ b/test/session-continuity.test.js @@ -379,3 +379,139 @@ describe('session-continuity: turn-1 stability (root anchor)', () => { assert.notEqual(d2t2, d1t2, 'once diverged, the two dialogs hold independent ids'); }); }); + +describe('session-continuity: compaction survival (root fallback)', () => { + beforeEach(() => _resetForTests()); + afterEach(() => _resetForTests()); + + // Drive resolve → commit → resolve exactly as the handler does, chaining the + // real assistant reply into the next turn's history. + function turn(caller, historyRef, userText, assistantText) { + historyRef.push({ role: 'user', content: userText }); + const id = resolveSessionId(caller, historyRef, ENV); + historyRef.push({ role: 'assistant', content: assistantText }); + commitAfterResponse(caller, historyRef, ENV); + return id; + } + + it('a compacted history with rewritten pairs still resolves the committed session via the root anchor', () => { + const h = [{ role: 'system', content: 'sys' }]; + const id1 = turn('c1', h, 'build a parser', 'ok'); + const id2 = turn('c1', h, 'add error handling', 'done'); + const id3 = turn('c1', h, 'add tests', 'done'); + assert.equal(id1, id2); + assert.equal(id2, id3); + + // Client compaction: the retained tail is rewritten so 0 of the committed + // pairs survive byte-for-byte, BUT the dialog's FIRST input turn survives + // verbatim (the root anchor). Pair evidence is gone → root fallback fires. + const compacted = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'build a parser' }, + { role: 'assistant', content: 'ok (compressed summary)' }, + { role: 'user', content: 'add tests' }, + ]; + const before = _getStoreSize(); + const resolved = resolveSessionId('c1', compacted, ENV); + assert.equal(resolved, id3, 'compacted history must re-associate through the root anchor'); + assert.equal(_getStoreSize(), before, 'a clean root re-association must not grow the store'); + }); + + it('two live states sharing the root anchor with no pair evidence are ambiguous → a NEW id forms (no hijack)', () => { + const h1 = []; + const d1t1 = turn('c1', h1, 'same opener', 'reply one'); + const d1t2 = turn('c1', h1, 'continue 1', 'more one'); + const h2 = []; + const d2t1 = turn('c1', h2, 'same opener', 'reply two'); + const d2t2 = turn('c1', h2, 'continue 2', 'more two'); + assert.equal(d1t1, d2t1, 'identical openers collide on turn 1 (same root anchor)'); + assert.notEqual(d1t2, d2t2, 'the two dialogs must have forked at turn 2'); + + // A compacted resolve whose rewritten pairs match no stored index and whose + // root anchor is shared by BOTH live states → ambiguous → assign to none. + const compacted = [ + { role: 'user', content: 'same opener' }, + { role: 'assistant', content: 'reply one (compressed summary)' }, + { role: 'user', content: 'continue 1' }, + ]; + const resolved = resolveSessionId('c1', compacted, ENV); + assert.notEqual(resolved, d1t2, 'ambiguous root must not be assigned to dialog 1'); + assert.notEqual(resolved, d2t2, 'ambiguous root must not be assigned to dialog 2'); + }); + + it('an EXPIRED state is evicted by the root fallback — a stale dialog does not resurrect through compaction', () => { + const h = []; + const id1 = turn('c1', h, 'stale opener', 'old reply'); + const id2 = turn('c1', h, 'stale followup', 'old more'); + assert.equal(id1, id2); + + // Busy-wait past a 1ms TTL so the stored state is stale on the next resolve. + const env = { ...ENV, DEVIN_CONNECT_SESSION_TTL_MS: '1' }; + const spinUntil = Date.now() + 5; + while (Date.now() < spinUntil) { /* let the TTL lapse */ } + + // Pair evidence wiped by compaction; the root anchor survives — but the only + // candidate is expired, so the fallback must evict it and form a NEW id. + const compacted = [ + { role: 'user', content: 'stale opener' }, + { role: 'assistant', content: 'old reply (compressed summary)' }, + { role: 'user', content: 'next turn after the lapse' }, + ]; + const resolved = resolveSessionId('c1', compacted, env); + assert.notEqual(resolved, id2, 'a TTL-expired session must not resurrect through the root fallback'); + }); +}); + +describe('session-continuity: tail-anchored overlap', () => { + beforeEach(() => _resetForTests()); + afterEach(() => _resetForTests()); + + function turn(caller, historyRef, userText, assistantText) { + historyRef.push({ role: 'user', content: userText }); + const id = resolveSessionId(caller, historyRef, ENV); + historyRef.push({ role: 'assistant', content: assistantText }); + commitAfterResponse(caller, historyRef, ENV); + return id; + } + + it('a divergent dialog sharing only an early pair does NOT resolve to the committed session (prefix-only run scores 0)', () => { + const h = []; + turn('c1', h, 'A1', 'O1'); + turn('c1', h, 'A2', 'O2'); + const committedId = turn('c1', h, 'A3', 'O3'); + + // Shares the opener + first pair (a PREFIX-only run), then answers A2 + // differently. Tail-anchored overlap must score 0 → no claim on the session. + const divergent = [ + { role: 'user', content: 'A1' }, { role: 'assistant', content: 'O1' }, + { role: 'user', content: 'A2' }, { role: 'assistant', content: 'an unrelated answer' }, + { role: 'user', content: 'A3' }, + ]; + const before = _getStoreSize(); + const divId = resolveSessionId('c1', divergent, ENV); + assert.notEqual(divId, committedId, 'a prefix-only run must never hijack the committed session'); + assert.ok(_getStoreSize() > before, 'the divergent dialog must fork its own state'); + }); + + it('the true continuation (suffix/tail match) resolves back to its own session — and the fork stays stable on its own id', () => { + const h = []; + turn('c1', h, 'A1', 'O1'); + turn('c1', h, 'A2', 'O2'); + const committedId = turn('c1', h, 'A3', 'O3'); + + // True continuation: the full history replayed + a fresh user turn — the + // committed tail (suffix) matches the incoming tail → resolves to ITS session. + const contId = resolveSessionId('c1', [...h, { role: 'user', content: 'A4' }], ENV); + assert.equal(contId, committedId, 'the suffix/tail match must resolve to the committed session'); + + // The divergent fork is a session of its own: it keeps ITS id on the next turn. + const divergent = [ + { role: 'user', content: 'A1' }, { role: 'assistant', content: 'O1' }, + { role: 'user', content: 'A2' }, { role: 'assistant', content: 'an unrelated answer' }, + { role: 'user', content: 'A3' }, + ]; + const divId = resolveSessionId('c1', divergent, ENV); + const divNext = resolveSessionId('c1', [...divergent, { role: 'user', content: 'A4' }], ENV); + assert.equal(divNext, divId, 'the divergent fork must stay stable on its own id'); + }); +});