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
107 changes: 107 additions & 0 deletions cli/__tests__/adapters.claude.context-tokens.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, test } from '@jest/globals';
import { EventEmitter } from 'events';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import claude from '../src/lib/adapters/claude.js';

// The child writes its transcript where its own HOME points, and the adapter
// must read there, not in the operator's ~/.claude. A public seat's child runs
// under an isolated HOME, so the operator path would find nothing.
const fakeChild = () => {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = () => {};
setTimeout(() => {
proc.stdout.emit('data', Buffer.from('ok'));
proc.emit('close', 0);
}, 0);
return proc;
};

describe('claude adapter reports the turn context from the child HOME', () => {
test('reads the transcript under the child HOME and returns its token count', async () => {
const childHome = mkdtempSync(join(tmpdir(), 'claude-home-'));
const cwd = join(childHome, 'work');
try {
const impl = (_cmd, args) => {
const flag = args.includes('--resume') ? '--resume' : '--session-id';
const sid = args[args.indexOf(flag) + 1];
const dir = join(childHome, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}.jsonl`), `${JSON.stringify({
type: 'assistant',
message: { usage: { input_tokens: 4, cache_creation_input_tokens: 100, cache_read_input_tokens: 2000 } },
})}\n`);
return fakeChild();
};
const res = await claude.spawn('hi', {
sessionId: 'sid-ctx',
cwd,
env: { HOME: childHome },
memoryLongTerm: '',
_spawnImpl: impl,
});
expect(res.newSessionId).toBe('sid-ctx');
expect(res.contextTokens).toBe(2104);
} finally {
rmSync(childHome, { recursive: true, force: true });
}
});

test('a turn with no transcript reports unknown, so the seat keeps resuming', async () => {
const childHome = mkdtempSync(join(tmpdir(), 'claude-home-'));
try {
const res = await claude.spawn('hi', {
sessionId: 'sid-none',
cwd: join(childHome, 'work'),
env: { HOME: childHome },
memoryLongTerm: '',
_spawnImpl: () => fakeChild(),
});
expect(res.contextTokens).toBeNull();
} finally {
rmSync(childHome, { recursive: true, force: true });
}
});
});

// bwrap wrapping is Linux-only (wrapArgvWithBwrap throws on macOS), so this
// jail case runs in Linux CI and is skipped on a Mac.
const jailTest = process.platform === 'linux' ? test : test.skip;

describe('claude adapter reads the transcript inside a bwrap jail', () => {
jailTest('bwrap sets the child HOME to the workspace, and the count is read there', async () => {
const hostHome = mkdtempSync(join(tmpdir(), 'claude-host-'));
const cwd = join(hostHome, 'workspace');
mkdirSync(cwd, { recursive: true });
try {
const impl = (cmd, args) => {
const flag = args.includes('--resume') ? '--resume' : '--session-id';
const sid = args[args.indexOf(flag) + 1];
const dir = join(cwd, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}.jsonl`), `${JSON.stringify({
type: 'assistant',
message: { usage: { input_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 150000 } },
})}\n`);
return fakeChild();
};
const res = await claude.spawn('hi', {
sessionId: 'sid-jail',
cwd,
env: { PATH: process.env.PATH, HOME: hostHome },
environment: { sandbox: { trust: 'public', mode: 'bwrap' } },
memoryLongTerm: '',
_spawnImpl: impl,
_detectBwrap: () => ({ available: true, path: 'bwrap' }),
});
expect(res.newSessionId).toBe('sid-jail');
expect(res.contextTokens).toBe(150003);
} finally {
rmSync(hostHome, { recursive: true, force: true });
}
});
});
213 changes: 213 additions & 0 deletions cli/__tests__/session-recycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import {
RECYCLE_DEFAULTS,
buildRecycleBriefing,
claudeConfigDirFor,
claudeContextTokens,
claudeTranscriptPath,
contextTokensFromTranscript,
idleMinutesSince,
recycleThresholds,
shouldStartFreshSession,
} from '../src/lib/session-recycle.js';
import { getSessionEntry, setSession } from '../src/lib/session-store.js';

const NOW = Date.parse('2026-10-24T04:00:00.000Z');
const minutesAgo = (minutes) => new Date(NOW - minutes * 60_000).toISOString();

const assistantLine = (usage) => JSON.stringify({
type: 'assistant',
message: { usage },
});

describe('shouldStartFreshSession', () => {
test('idle at the threshold and context at the threshold starts fresh', () => {
expect(shouldStartFreshSession({
lastTurn: minutesAgo(60),
contextTokens: 128000,
now: NOW,
})).toBe(true);
});

test('a recent turn resumes even with a large context', () => {
expect(shouldStartFreshSession({
lastTurn: minutesAgo(5),
contextTokens: 400000,
now: NOW,
})).toBe(false);
});

test('an idle seat with a small context resumes', () => {
expect(shouldStartFreshSession({
lastTurn: minutesAgo(600),
contextTokens: 20000,
now: NOW,
})).toBe(false);
});

test('no measured context means resume, never recycle blind', () => {
expect(shouldStartFreshSession({
lastTurn: minutesAgo(600),
contextTokens: null,
now: NOW,
})).toBe(false);
});

test('no last turn means resume', () => {
expect(shouldStartFreshSession({
lastTurn: undefined,
contextTokens: 500000,
now: NOW,
})).toBe(false);
});

test('thresholds are configurable per install', () => {
const thresholds = { idleMinutes: 10, contextTokens: 1000 };
expect(shouldStartFreshSession({
lastTurn: minutesAgo(10),
contextTokens: 1000,
now: NOW,
thresholds,
})).toBe(true);
});
});

describe('recycleThresholds', () => {
test('defaults match the ruling', () => {
expect(recycleThresholds({})).toEqual(RECYCLE_DEFAULTS);
expect(RECYCLE_DEFAULTS).toEqual({ idleMinutes: 60, contextTokens: 128000 });
});

test('environment overrides apply; invalid values fall back to the default', () => {
expect(recycleThresholds({
COMMONLY_SESSION_RECYCLE_IDLE_MINUTES: '15',
COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS: '64000',
})).toEqual({ idleMinutes: 15, contextTokens: 64000 });
expect(recycleThresholds({
COMMONLY_SESSION_RECYCLE_IDLE_MINUTES: '-3',
COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS: 'lots',
})).toEqual(RECYCLE_DEFAULTS);
});
});

describe('idleMinutesSince', () => {
test('whole minutes since the last turn', () => {
expect(idleMinutesSince(minutesAgo(61.5), NOW)).toBe(61);
});

test('an unparseable turn time is null', () => {
expect(idleMinutesSince('not a date', NOW)).toBeNull();
});
});

describe('buildRecycleBriefing', () => {
test('names the restart, the idle time, the context size and the role', () => {
const text = buildRecycleBriefing({
agentName: 'nova', idleMinutes: 75, contextTokens: 140000,
});
expect(text).toContain('=== Session restart ===');
expect(text).toContain('idle 75 min');
expect(text).toContain('about 140000 context tokens');
expect(text).toContain('Role: nova.');
});
});

describe('claudeTranscriptPath', () => {
test('encodes every non-alphanumeric character of the cwd as a dash', () => {
expect(claudeTranscriptPath({
cwd: '/Users/x/agents/nova.work',
sessionId: 'abc',
configDir: '/cfg',
})).toBe('/cfg/projects/-Users-x-agents-nova-work/abc.jsonl');
});

test('no cwd or no session means no path', () => {
expect(claudeTranscriptPath({ cwd: null, sessionId: 'abc', configDir: '/cfg' })).toBeNull();
expect(claudeTranscriptPath({ cwd: '/x', sessionId: null, configDir: '/cfg' })).toBeNull();
});
});

describe('contextTokensFromTranscript', () => {
test('uses the last assistant turn: input plus both cache counts', () => {
const text = [
assistantLine({ input_tokens: 5, cache_creation_input_tokens: 10, cache_read_input_tokens: 100 }),
JSON.stringify({ type: 'user', message: { content: 'hi' } }),
assistantLine({ input_tokens: 2, cache_creation_input_tokens: 2939, cache_read_input_tokens: 354133 }),
'',
].join('\n');
expect(contextTokensFromTranscript(text)).toBe(2 + 2939 + 354133);
});

test('a cut first line and malformed lines are skipped', () => {
const text = [
'{"type":"assis',
'not json',
assistantLine({ input_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 9 }),
].join('\n');
expect(contextTokensFromTranscript(text)).toBe(10);
});

test('a transcript with no assistant usage is unknown', () => {
expect(contextTokensFromTranscript(JSON.stringify({ type: 'user' }))).toBeNull();
});
});

describe('claudeContextTokens', () => {
let configDir;
let cwd;
beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), 'recycle-cfg-'));
cwd = '/work/agent-one';
});
afterEach(() => {
rmSync(configDir, { recursive: true, force: true });
});

test('reads the transcript for the session in the encoded cwd folder', () => {
const dir = join(configDir, 'projects', '-work-agent-one');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'sess-1.jsonl'), assistantLine({
input_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 3,
}));
expect(claudeContextTokens({ cwd, sessionId: 'sess-1', configDir })).toBe(6);
});

test('a missing transcript is unknown, so the seat resumes as before', () => {
expect(claudeContextTokens({ cwd, sessionId: 'nope', configDir })).toBeNull();
});
});

describe('session store contextTokens', () => {
const agent = `recycle-test-${process.pid}-${Date.now()}`;
const pod = 'pod-recycle';
afterEach(() => {
rmSync(join(process.env.HOME, '.commonly', 'sessions', `${agent}.json`), { force: true });
});

test('setSession stores contextTokens and getSessionEntry returns it', () => {
setSession(agent, pod, 'sid-9', { contextTokens: 150000 });
const entry = getSessionEntry(agent, pod);
expect(entry.sessionId).toBe('sid-9');
expect(entry.contextTokens).toBe(150000);
expect(typeof entry.lastTurn).toBe('string');
});

test('setSession without contextTokens stores none', () => {
setSession(agent, pod, 'sid-10');
expect(getSessionEntry(agent, pod)).not.toHaveProperty('contextTokens');
});
});

describe('claudeConfigDirFor', () => {
test('an explicit CLAUDE_CONFIG_DIR wins', () => {
expect(claudeConfigDirFor({ CLAUDE_CONFIG_DIR: '/cfg', HOME: '/home/x' })).toBe('/cfg');
});

test('otherwise the child HOME, so a public seat looks in its own state', () => {
expect(claudeConfigDirFor({ HOME: '/state/public-1' })).toBe('/state/public-1/.claude');
});
});
Loading
Loading