Merge pull request #353 from timkjr/omp-mode

feat: add OMP (Oh My Pi) as a new session backend
This commit is contained in:
Codeman maintainer
2026-08-30 16:16:26 +02:00
54 changed files with 2258 additions and 108 deletions
+1 -1
View File
@@ -401,7 +401,7 @@ describe('Session Manager unified list', () => {
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
historyOptions.onActivate();
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
});
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
+26
View File
@@ -329,6 +329,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => {
mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true });
writeFileSync(join(home, '.omp', 'agent', 'config.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}');
writeFileSync(join(home, '.omp', 'agent', 'models.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), '');
// Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions).
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), '');
mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({
src: join(home, '.omp', 'agent', 'sessions'),
dst: '/home/agent/.omp/agent/sessions',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.omp/agent/config.yml');
expect(dests).toContain('/home/agent/.omp/agent/mcp.json');
expect(dests).toContain('/home/agent/.omp/agent/models.yml');
expect(dests).toContain('/home/agent/.omp/agent/settings.yml');
expect(dests).not.toContain('/home/agent/.omp/agent/agent.db');
expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false);
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
+2 -1
View File
@@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'pi',
'grok',
'deepseek',
'omp',
'shell',
]);
});
@@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
);
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
expect(offered).toContain('antigravity');
expect(offered).toContain('omp');
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable');
+1
View File
@@ -86,6 +86,7 @@ export function createMockRouteContext(options?: {
getSession: vi.fn(),
setSession: vi.fn(),
removeSession: vi.fn(),
demoteOrRemoveSession: vi.fn(() => 'removed' as const),
getSettings: vi.fn(() => ({})),
setSettings: vi.fn(),
getRalphLoopState: vi.fn(() => ({})),
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the OMP CLI resolver wrapper.
*
* OMP is a resolver with a version probe: `omp` is a short binary name, so a
* resolved path is only accepted once `omp --version` prints an `omp/<semver>`
* string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOmpResolverForTest } from '../src/utils/omp-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('OMP CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/omp';
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '17.4.0',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `omp` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/omp';
const genuine = '/login-shell/bin/omp';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null));
const resolver = createOmpResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/omp';
let now = 0;
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('17.4.0');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeOmpVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `omp` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'omp');
writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createOmpResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27,
* and its follow-on fix for the sibling-aliasing bug found in upstream PR
* review (Ark0N/Codeman#353).
*
* Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp
* conversation as a side effect on `this._ompConfig`. That is correct ONLY
* immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine
* remote reattach) — never while merely building options that might not
* lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`,
* which startInteractive() calls unconditionally (including for a genuinely
* brand-new session, and for a boot-recovery reattach to a pane that turns
* out to still be alive): a fresh "Run OMP" click in a working directory
* with any prior omp history silently launched `--resume <old-id>` instead
* of a clean `omp` invocation, and — with two omp tabs in the same case dir
* — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to
* whichever sibling's file happened to be newest on disk, even though
* nothing was actually being respawned. Resolution now happens only inside
* `_pinOmpRespawnId()`, called by a caller that has already confirmed a
* real respawn is happening.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/types.js';
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
function seedOmpSessionFile(id: string) {
mkdirSync(workingDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
// resolveAndClaimOmpSessionId() verifies the file's own header (not just
// the filename), mirroring the real `omp` session-file shape — the
// header's `cwd` must match `workingDir` for the candidate to count.
const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`;
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header);
}
it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => {
seedOmpSessionFile('old-conversation-id');
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => {
// Regression for the sibling-aliasing bug: pinning must never be a side
// effect of merely building respawn options for a pane that might still
// be alive (isPaneDead is unconditionally false under IS_TEST_MODE,
// which is what a real "just reattaching, nothing died" boot recovery
// looks like from Session's perspective).
seedOmpSessionFile('sibling-conversation-id');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => {
seedOmpSessionFile('real-omp-uuid');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(session.claudeSessionId).toBe('real-omp-uuid');
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
describe('OMP mode schemas', () => {
it('accepts OMP session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: {
model: 'crof/glm-5.2',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig).toEqual({
model: 'crof/glm-5.2',
});
});
it('accepts OMP quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'omp-case',
mode: 'omp',
ompConfig: {
resumeSessionId: 'session-1234abcd',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd');
});
it('rejects unsafe OMP model strings', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: { model: 'omp; rm -rf /' },
})
).toThrow();
});
it('allows OMP_* env overrides and still rejects unknown prefixes', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
envOverrides: { OMP_PROFILE: 'work' },
});
expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' });
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { RANDOM_PREFIX_KEY: 'x' },
})
).toThrow();
});
});
describe('OMP spawn command', () => {
it('builds a bare omp command when no config is sent', () => {
const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' });
expect(cmd).toBe('omp');
});
it('passes --model and --resume, and drops unsafe ids', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' },
})
).toBe('omp --model crof/glm-5.2 --resume session-99');
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'x; rm -rf /' },
})
).toBe('omp');
});
it('continues the most recent session when no explicit resume id is given', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { continueSession: true },
})
).toBe('omp --continue');
});
it('prefers an explicit --resume id over --continue', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'session-99', continueSession: true },
})
).toBe('omp --resume session-99');
});
it('drops unsafe model strings from the spawn command', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'a`b' },
})
).toBe('omp');
});
});
describe('OMP mode gates', () => {
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
expect(isExternalCliMode('omp')).toBe(true);
});
it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => {
expect(isAltScreenStripMode('omp')).toBe(false);
});
it('has docker/remote default commands', () => {
expect(defaultDockerCommandForMode('omp')).toBe('exec omp');
// Routed through an interactive login shell so per-user PATH entries resolve —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\'');
});
});
describe('OMP multi-user clamp: the env-var half', () => {
// Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to
// gate (buildOmpCommand() only ever emits --model/--resume/--continue), so
// the only privilege surface is the two credential-resolution env vars the
// OMP_* prefix admits.
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('strips OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
OMP_AUTH_BROKER_URL: 'https://attacker.example/broker',
OMP_AUTH_BROKER_TOKEN: 'stolen-token',
OMP_PROFILE: 'default',
});
expect(out).toEqual({ OMP_PROFILE: 'default' });
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' };
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @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 } 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, '-'));
});
});
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('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();
});
});
+10
View File
@@ -20,6 +20,7 @@ import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
@@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
listDeepSeekProfiles: vi.fn(() => []),
resolveDefaultDeepSeekProfile: vi.fn(() => null),
}));
vi.mock('../src/utils/omp-cli-resolver.js', () => ({
isOmpAvailable: vi.fn(() => false),
resolveOmpDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isPiAvailable).mockReturnValue(true);
vi.mocked(isGrokAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
vi.mocked(isOmpAvailable).mockReturnValue(true);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => {
grok: false,
deepseek: false,
deepseekBinary: false,
omp: true,
cloudflared: true,
git: true,
});
@@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => {
isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+143
View File
@@ -0,0 +1,143 @@
/**
* @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession()
* threads the row's own mode through session creation via a `modeConfigKey` map
* (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via
* DELETE. codex/gemini/antigravity were missing from that map, so resuming one of
* their rows created a session with NO continuation while still deleting the row
* it came from — data loss dressed as a fix. The correction: only retire the row
* when the new session actually continues something.
*
* Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts.
* `fetch` is a shared mutable stub so each test can inspect exactly which requests
* fired without a real network/server.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi, beforeEach } from 'vitest';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */
let currentFetch: (...args: unknown[]) => unknown = () => {
throw new Error('fetch not stubbed for this test');
};
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
function makeApp() {
return {
terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() },
cases: [],
resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise<void>,
_closeFolderHistoryModal: vi.fn(),
_resolveResumeName: () => 'w1-case',
loadAppSettingsFromStorage: () => ({}),
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}
/** DELETE calls the fetch mock recorded. */
function deleteCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE')
.map(([url]: [string]) => url);
}
/** POST /api/sessions body the fetch mock recorded. */
function createBody(fetchMock: ReturnType<typeof vi.fn>): any {
const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions');
return call ? JSON.parse((call[1] as { body: string }).body) : undefined;
}
function stubFetch(newSessionId: string): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (url: string) => {
if (url === '/api/sessions') {
return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) };
}
return { json: async () => ({ success: true }) };
});
currentFetch = fetchMock;
return fetchMock;
}
describe('resumeHistorySession: row retirement is gated on actual continuation', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = stubFetch('new-session-id');
});
it.each(['codex', 'gemini', 'antigravity'])(
'does NOT retire the old row for %s (no continuation is wired for it)',
async (mode) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)).toMatchObject({ mode });
expect(createBody(fetchMock).codexConfig).toBeUndefined();
expect(createBody(fetchMock).geminiConfig).toBeUndefined();
expect(createBody(fetchMock).antigravityConfig).toBeUndefined();
expect(deleteCalls(fetchMock)).toEqual([]);
}
);
it.each([
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('retires the old row for deepseek (resumeSession is wired)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek');
expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude');
expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' });
expect(deleteCalls(fetchMock)).toEqual([]);
});
it('never retires when the new session id equals the old one (no-op resume)', async () => {
fetchMock = stubFetch('same-id');
const app = makeApp();
await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp');
expect(deleteCalls(fetchMock)).toEqual([]);
});
});
+30
View File
@@ -341,6 +341,36 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({
id: 'ghost-session',
owner: undefined,
} as never);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/ghost-session',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session');
expect(harness.ctx.cleanupSession).not.toHaveBeenCalled();
// Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
});
it('404s a persisted-only session id the state store does not recognize either', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/truly-nonexistent',
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled();
});
});
// ========== DELETE /api/sessions (delete all) ==========
+22 -2
View File
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG');
});
it('accepts OMP mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
expect(app.runMode).toBe('omp');
expect(runBtnLabel.textContent).toBe('Run OMP');
});
});
describe('Run launch synchronization', () => {
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
omp: false,
cloudflared: false,
};
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.style.display).toBe('none');
expect(modeBtns.omp.style.display).toBe('flex');
// Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE');
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));