Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 49 additions & 3 deletions src/session-continuity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
41 changes: 41 additions & 0 deletions test/chat-reuse-log.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// The DEVIN_CONNECT session-reuse log line must identify WHICH account served
// the resumed session (acct=<account id>), 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=<account id> (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',
);
});
});
27 changes: 27 additions & 0 deletions test/mutations/session-continuity-compaction-survival.json
Original file line number Diff line number Diff line change
@@ -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; }"
}
]
}
136 changes: 136 additions & 0 deletions test/session-continuity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});