Files
Codeman/test/omp-session-resolver.test.ts
T
timkjrandClaude Sonnet 5 797f0d387c fix(remote): address review feedback on omp/claude respawn continuity
- Remote omp command now renders through buildSpawnCommandFromRegistry
  (the mode-agnostic engine local/docker spawns use) instead of the
  buildOmpCommand() the CLI-registry refactor deleted.
- Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip
  host-local ~/.omp resolution entirely for a remote session and fall
  back to --continue: that resolver only ever reads THIS host's
  filesystem, which is meaningless (and could wrongly alias an
  unrelated local conversation) for a conversation that lives on the
  remote host.
- Remote-claude launch now honors an explicit resumeSessionId distinct
  from sessionId (mirrors claudeDockerPaneCommand's shape), and
  validates sessionId the same way that sibling does before
  interpolating it into the remote shell command.
- Add the still-missing header-cwd half of the trailing-slash test,
  and document respawn/reattach continuation + auto-reconnect-vs-
  clean-exit in docs/remote-sessions.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 22:11:54 -05:00

169 lines
7.8 KiB
TypeScript

/**
* @fileoverview Tests for OMP session-id resolution from disk.
*
* Pins the home-relative directory mangling bug found 2026-08-27: omp
* collapses a home-relative workingDir to its home-relative remainder BEFORE
* dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's
* `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was
* originally written to mirror. Getting this wrong doesn't throw — it just
* makes findLatestOmpSessionId() silently return null for every case under
* $HOME (virtually all real Codeman cases), so continuation pinning quietly
* degraded to omp's own ambiguous `--continue` while appearing to work in
* manual testing done entirely under /tmp (which sits outside $HOME and was
* mangled correctly by coincidence).
*
* test/setup.ts gives this file its own temp $HOME, so homedir() below is
* already sandboxed — writing real files under it is safe and exercises the
* exact home-relative path the bug hid behind.
*/
import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
findLatestOmpSessionId,
mangleOmpWorkingDir,
resolveAndClaimOmpSessionId,
} from '../src/utils/omp-session-resolver.js';
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
describe('mangleOmpWorkingDir', () => {
it('strips the home prefix before dash-replacing a home-relative path', () => {
const home = homedir();
expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase');
});
it('dash-replaces a path outside $HOME as-is', () => {
expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case');
});
it('treats workingDir === home as the empty remainder', () => {
expect(mangleOmpWorkingDir(homedir())).toBe('');
});
it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => {
const sibling = `${homedir()}-other/dev/foo`;
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
});
it('normalizes a trailing slash so a remote case path resolves to the same dir', () => {
// Regression (2026-08-29): remote case paths are stored verbatim with a
// trailing slash (e.g. `/home/user/dotfiles/`), but omp persists sessions
// under the slash-less mangle (`-dotfiles`). Before the fix this produced
// `-dotfiles-`, readdirSync returned null for an existing dir, and OMP
// respawn pinning silently degraded to the ambiguous `--continue`.
expect(mangleOmpWorkingDir(join(homedir(), 'dotfiles') + '/')).toBe('-dotfiles');
});
});
describe('findLatestOmpSessionId', () => {
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('finds the newest session file under a home-relative workingDir', () => {
const workingDir = join(homedir(), 'codeman-cases', 'testcase');
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}');
const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl');
writeFileSync(newer, '{}');
// Force a deterministic mtime order regardless of filesystem timestamp resolution.
const now = Date.now() / 1000;
utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now);
utimesSync(newer, now + 1, now + 1);
expect(findLatestOmpSessionId(workingDir)).toBe('newer-id');
});
it('returns null when the mangled directory does not exist', () => {
expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull();
});
});
describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => {
// Sibling of the directory-mangle trailing-slash regression above, but for
// the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally
// verifies each candidate file's own header `cwd` against workingDir (the
// mangle is lossy, so the filename-derived id alone isn't enough — see the
// function's doc comment). A remote case's workingDir carries a trailing
// slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does;
// without stripTrailingSlash() on BOTH sides of that comparison, a real
// on-disk session would be found by directory but rejected by the cwd
// check, silently degrading pinning to the ambiguous `--continue`.
const workingDirNoSlash = join(homedir(), 'dotfiles');
const workingDirWithSlash = `${workingDirNoSlash}/`;
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('matches a header cwd with no trailing slash against a workingDir that has one', () => {
mkdirSync(sessionDir, { recursive: true });
const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`;
writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header);
expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid');
});
});
describe('resolveOmpConfigForCreate', () => {
// The exact pipeline "resume this OMP row from the history list" drives:
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
// must come back with resumeSessionId PINNED to the real omp transcript
// uuid, not left as the ambiguous continueSession flag alone. This was the
// one path flagged by review as having zero coverage despite being the
// exact mechanism the whole resolver module exists to serve.
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('pins resumeSessionId from disk when resuming with only continueSession set', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true });
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' });
});
it('does not attempt resolution when resumeSessionId is already explicit', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, {
continueSession: true,
resumeSessionId: 'already-pinned',
});
// Must return the caller's id unchanged, never overwrite it with whatever
// happens to be newest on disk.
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' });
});
it('leaves ompConfig unchanged when continueSession is not set', () => {
const resolved = resolveOmpConfigForCreate('omp', workingDir, {});
expect(resolved).toEqual({});
});
it('leaves ompConfig unchanged when nothing is on disk to resolve', () => {
const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), {
continueSession: true,
});
expect(resolved).toEqual({ continueSession: true });
});
it('returns undefined for a non-omp mode regardless of ompConfig', () => {
expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined();
});
it('returns undefined when ompConfig is undefined', () => {
expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined();
});
});