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 test/derive-project-path.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');

const { deriveProjectPath, resolveWorktreePath } = require('../derive-project-path');

function mkTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-dpp-'));
}

function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}

test('resolveWorktreePath collapses /<repo>/.claude/worktrees/<name> back to <repo> when parent exists', () => {
const tmp = mkTmp();
try {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const worktree = path.join(repo, '.claude', 'worktrees', 'agent-abc');
assert.equal(resolveWorktreePath(worktree), repo);
} finally {
cleanup(tmp);
}
});

test('resolveWorktreePath collapses /<repo>/.claude-worktrees/<name> back to <repo>', () => {
const tmp = mkTmp();
try {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const worktree = path.join(repo, '.claude-worktrees', 'foo');
assert.equal(resolveWorktreePath(worktree), repo);
} finally {
cleanup(tmp);
}
});

test('resolveWorktreePath collapses /<repo>/.worktrees/<name> back to <repo>', () => {
const tmp = mkTmp();
try {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const worktree = path.join(repo, '.worktrees', 'bar');
assert.equal(resolveWorktreePath(worktree), repo);
} finally {
cleanup(tmp);
}
});

test('resolveWorktreePath handles trailing-slash variant', () => {
const tmp = mkTmp();
try {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const worktreeWithSlash = path.join(repo, '.worktrees', 'bar') + '/';
assert.equal(resolveWorktreePath(worktreeWithSlash), repo);
} finally {
cleanup(tmp);
}
});

test('resolveWorktreePath returns input unchanged when the parent dir does not exist on disk', () => {
// /nonexistent-xyzzy-12345/.claude/worktrees/agent-foo — regex matches, but parent dir absent
const fake = '/nonexistent-xyzzy-12345/.claude/worktrees/agent-foo';
assert.equal(resolveWorktreePath(fake), fake);
});

test('resolveWorktreePath returns input unchanged when the path does not match the worktree pattern', () => {
assert.equal(resolveWorktreePath('/repo/src/foo'), '/repo/src/foo');
assert.equal(resolveWorktreePath('/repo/.claude/agents/foo'), '/repo/.claude/agents/foo');
// Worktrees segment but two extra components (nested under worktree) — must not match
assert.equal(resolveWorktreePath('/repo/.worktrees/foo/bar'), '/repo/.worktrees/foo/bar');
});

test('resolveWorktreePath passes falsy input through unchanged without throwing', () => {
assert.equal(resolveWorktreePath(null), null);
assert.equal(resolveWorktreePath(undefined), undefined);
assert.equal(resolveWorktreePath(''), '');
});

test('deriveProjectPath end-to-end: jsonl with worktree cwd resolves to parent repo', () => {
const tmp = mkTmp();
try {
// Real on-disk repo so existsSync returns true
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const worktreeCwd = path.join(repo, '.claude', 'worktrees', 'agent-x');
// worktreeCwd itself doesn't need to exist; only its derived parent does

// The folder we feed deriveProjectPath is a "projects/foo" style dir
// containing a single jsonl whose first cwd line points at the worktree.
const folder = path.join(tmp, 'project-folder');
fs.mkdirSync(folder);
fs.writeFileSync(
path.join(folder, 'session-1.jsonl'),
JSON.stringify({ type: 'user', cwd: worktreeCwd }) + '\n',
'utf8'
);

assert.equal(deriveProjectPath(folder), repo);
} finally {
cleanup(tmp);
}
});
197 changes: 197 additions & 0 deletions test/session-transitions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');

const sessionTransitions = require('../session-transitions');
const { detectSubagentTransitions, init } = sessionTransitions;

function mkTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-st-'));
}

function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}

/** Build a mock mainWindow that records every webContents.send call. */
function makeMockWindow() {
const events = [];
return {
isDestroyed: () => false,
webContents: {
send: (channel, payload) => events.push({ channel, payload }),
},
_events: events,
};
}

/** Initialize the module with mocks. Returns the recorded-events array. */
function setupModule() {
const win = makeMockWindow();
init({
PROJECTS_DIR: '/unused',
activeSessions: new Map(),
getMainWindow: () => win,
log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} },
rekeyMcpServer: () => {},
});
return win._events;
}

/** Create N agent jsonl files under <folder>/<sessionId>/subagents/ and
* set their mtimes to (now - ageMs). Returns the subagents dir. */
function seedAgents(folder, sessionId, agents) {
const subDir = path.join(folder, sessionId, 'subagents');
fs.mkdirSync(subDir, { recursive: true });
for (const { id, ageMs = 0, content = '' } of agents) {
const filePath = path.join(subDir, `agent-${id}.jsonl`);
fs.writeFileSync(filePath, content, 'utf8');
if (ageMs) {
const t = (Date.now() - ageMs) / 1000;
fs.utimesSync(filePath, t, t);
}
}
return subDir;
}

test('bootstrap call with 5 pre-existing subagents emits zero events and populates the map', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent-session';
seedAgents(tmp, sessionId, [
{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }, { id: 'a4' }, { id: 'a5' },
]);

const session = {}; // knownSubagents undefined → bootstrap
detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 0, 'bootstrap must not emit IPC');
assert.ok(session.knownSubagents instanceof Map);
assert.equal(session.knownSubagents.size, 5);
} finally {
cleanup(tmp);
}
});

test('bootstrap marks an old-mtime agent (>60s) as completed: true', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent';
seedAgents(tmp, sessionId, [{ id: 'oldie', ageMs: 120_000 }]); // 2 minutes old

const session = {};
detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 0);
const entry = session.knownSubagents.get('oldie');
assert.ok(entry, 'expected an entry for oldie');
assert.equal(entry.completed, true);
assert.ok(entry._completedAt, 'expected _completedAt to be stamped');
} finally {
cleanup(tmp);
}
});

test('bootstrap marks a fresh-mtime agent as completed: false (lifecycle continues)', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent';
seedAgents(tmp, sessionId, [{ id: 'fresh', ageMs: 5_000 }]); // 5s old, well under 60s

const session = {};
detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 0, 'bootstrap must still be silent for fresh agents');
const entry = session.knownSubagents.get('fresh');
assert.ok(entry);
assert.equal(entry.completed, false);
assert.equal(entry._completedAt, null);
} finally {
cleanup(tmp);
}
});

test('post-bootstrap: a brand-new agent file emits exactly one subagent-spawned event', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent';
// First, bootstrap with empty subagents dir
fs.mkdirSync(path.join(tmp, sessionId, 'subagents'), { recursive: true });
const session = {};
detectSubagentTransitions(sessionId, session, tmp);
assert.equal(events.length, 0);
assert.equal(session.knownSubagents.size, 0);

// Now drop in a new agent file and re-run
seedAgents(tmp, sessionId, [{ id: 'newcomer' }]);
detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 1, `expected 1 event, got ${events.length}`);
assert.equal(events[0].channel, 'subagent-spawned');
assert.equal(events[0].payload.parentSessionId, sessionId);
assert.equal(events[0].payload.agentId, 'newcomer');
assert.equal(session.knownSubagents.get('newcomer').completed, false);
} finally {
cleanup(tmp);
}
});

test('post-bootstrap with no new agents emits zero events (IPC-flood regression)', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent';
seedAgents(tmp, sessionId, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]);

const session = {};
// Bootstrap absorbs all three silently
detectSubagentTransitions(sessionId, session, tmp);
assert.equal(events.length, 0);

// Subsequent flushes with no new files must stay silent
detectSubagentTransitions(sessionId, session, tmp);
detectSubagentTransitions(sessionId, session, tmp);
detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 0, 'no events should fire when nothing changed');
} finally {
cleanup(tmp);
}
});

test('completion: agent alive on call N, stable mtime for >30s on call N+1, emits subagent-completed', () => {
const events = setupModule();
const tmp = mkTmp();
try {
const sessionId = 'parent';
const subDir = seedAgents(tmp, sessionId, [{ id: 'slow' }]);
const filePath = path.join(subDir, 'agent-slow.jsonl');
const mtimeMs = fs.statSync(filePath).mtimeMs;

// Pre-seed knownSubagents as if a prior call already saw this agent alive
// and started the stability timer 31 seconds ago. This skips bootstrap mode
// since knownSubagents is already defined.
const session = { knownSubagents: new Map() };
session.knownSubagents.set('slow', {
mtimeMs, // same as file's actual mtime → "mtime stable"
completed: false,
_stableStart: Date.now() - 31_000, // stability started >30s ago
});

detectSubagentTransitions(sessionId, session, tmp);

assert.equal(events.length, 1, `expected 1 completion event, got ${events.length}: ${JSON.stringify(events)}`);
assert.equal(events[0].channel, 'subagent-completed');
assert.equal(events[0].payload.parentSessionId, sessionId);
assert.equal(events[0].payload.agentId, 'slow');
assert.equal(session.knownSubagents.get('slow').completed, true);
} finally {
cleanup(tmp);
}
});
Loading