mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #386 from irisitymichaelgrundberg/feat/codex-resume
Merging with the phone-overview resumeId fix and the two unified-list doc passages applied on master.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview A resumed codex session must keep its thread-id alias across
|
||||
* `start()`, not just at construction.
|
||||
*
|
||||
* `claudeSessionId` doubles as the generic "external transcript id" the unified
|
||||
* list folds a Past-Sessions row into its owning session by. For codex that id
|
||||
* is the rollout's thread id, and losing it is not cosmetic: the stale row stays
|
||||
* in PAST and still resumes, so clicking it starts a SECOND `codex resume` on a
|
||||
* thread already open in the live pane.
|
||||
*
|
||||
* The bug this pins: the alias was wired into the constructor only. `start()`
|
||||
* recomputes `claudeSessionId` at two further points — the mux branch and the
|
||||
* unconditional "third reset point" that runs after both the mux and direct-PTY
|
||||
* paths — and both listed only Claude's `resumeSessionId` and omp's. For codex
|
||||
* both are undefined, so every mux reattach and every boot recovery reset the
|
||||
* alias back to the Codeman id and the duplicate came back. The existing comment
|
||||
* at the third reset point already warned that omitting omp's fallback there
|
||||
* "stomps the mux branch's correctly-resolved OMP alias"; codex needed the same.
|
||||
*
|
||||
* Mirrors `test/omp-fresh-run-no-resume.test.ts`, which drives a real `Session`
|
||||
* against the in-memory tmux layer that vitest substitutes.
|
||||
*/
|
||||
import { mkdirSync, rmSync } 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';
|
||||
|
||||
describe('codex: a resumed thread id survives start()', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'codex-resume-alias');
|
||||
const THREAD_ID = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeSession(useMux: boolean): Session {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: THREAD_ID },
|
||||
mux: new TmuxManager(),
|
||||
useMux,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
it('carries the thread id from construction', () => {
|
||||
expect(makeSession(true).claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('still carries it after starting under mux', async () => {
|
||||
const session = makeSession(true);
|
||||
await session.startInteractive();
|
||||
expect(session.claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('refuses to start without mux at all, so the mux path is the only one to cover', async () => {
|
||||
// codex declares `requiresMux`, so there is no direct-PTY codex session for
|
||||
// the third reset point to run against on its own — the assertion above is
|
||||
// the whole surface.
|
||||
await expect(makeSession(false).startInteractive()).rejects.toThrow(/require tmux/i);
|
||||
});
|
||||
|
||||
it('a fresh codex session keeps the Codeman id, having no thread of its own', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({ workingDir, mode: 'codex', mux: new TmuxManager(), useMux: true });
|
||||
sessions.push(session);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Nothing to alias to yet — codex has not written the rollout. Such a
|
||||
// session is folded from the other side, by originator (codexThreadBySessionId).
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Reading codex's own rollout store for Past Sessions rows.
|
||||
*
|
||||
* Three of these assertions exist because the obvious implementation was
|
||||
* measured to be wrong against real files (codex CLI 0.152.1):
|
||||
*
|
||||
* - codex 0.152.1 emits NO `event_msg`/`user_message` rows at all. It writes
|
||||
* `event_msg`/`item_completed` carrying an `item.type` of `UserMessage`
|
||||
* instead, so a scanner that knew only the older shape found a prompt for
|
||||
* July rollouts and nothing for September ones.
|
||||
* - the `response_item` fallback sees codex's injected context, and on a real
|
||||
* store the FIRST such row is the repository's AGENTS.md every time. Taking
|
||||
* it literally titled every row with the same instructions block.
|
||||
* - codex spawns sub-agent threads into the same store, stamped
|
||||
* `thread_source: 'subagent'`. On the store this was built against they
|
||||
* outnumbered the threads a person can actually resume.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { appendFile, mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
scanCodexSessionsHistory,
|
||||
codexThreadBySessionId,
|
||||
__clearCodexIdentityCache,
|
||||
} from '../src/codex-transcript.js';
|
||||
|
||||
let home: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
|
||||
/** A rollout's opening line, as codex writes it. */
|
||||
const sessionMeta = (opts: { id: string; cwd?: string; threadSource?: string; originator?: string }) =>
|
||||
JSON.stringify({
|
||||
timestamp: '2026-09-02T07:05:37.421Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: opts.id,
|
||||
session_id: opts.id,
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
originator: opts.originator ?? 'codex-tui',
|
||||
...(opts.threadSource ? { thread_source: opts.threadSource } : {}),
|
||||
// The real thing embeds full base instructions here; padded so the file
|
||||
// clears the size floor and exercises the head window.
|
||||
base_instructions: { text: 'x'.repeat(500) },
|
||||
},
|
||||
});
|
||||
|
||||
/** codex 0.152.1's user-input row. */
|
||||
const itemCompletedUser = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'item_completed', item: { type: 'UserMessage', id: 'i1', content: [{ type: 'text', text }] } },
|
||||
});
|
||||
|
||||
/** The shape older codex versions wrote. */
|
||||
const legacyUserMessage = (text: string) =>
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } });
|
||||
|
||||
/** The last-resort shape, which also carries codex's injected context. */
|
||||
const responseItemUser = (text: string) =>
|
||||
JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'text', text }] } });
|
||||
|
||||
async function writeRollout(id: string, lines: string[], mtime?: Date): Promise<string> {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `rollout-2026-09-02T09-05-37-${id}.jsonl`);
|
||||
await writeFile(path, lines.join('\n') + '\n', 'utf-8');
|
||||
if (mtime) await utimes(path, mtime, mtime);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'codex-transcript-'));
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = home;
|
||||
__clearCodexIdentityCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('scanCodexSessionsHistory', () => {
|
||||
it('returns nothing when the store does not exist', async () => {
|
||||
process.env.CODEX_HOME = join(home, 'nope');
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the thread id, working directory and opening prompt', async () => {
|
||||
await writeRollout('01a060f0-0361-7f91-abde-b283020db0d7', [
|
||||
sessionMeta({ id: '01a060f0-0361-7f91-abde-b283020db0d7', cwd: '/repo/one' }),
|
||||
itemCompletedUser('Continue the audit log architecture'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sessionId).toBe('01a060f0-0361-7f91-abde-b283020db0d7');
|
||||
expect(rows[0].workingDir).toBe('/repo/one');
|
||||
expect(rows[0].firstPrompt).toBe('Continue the audit log architecture');
|
||||
expect(rows[0].sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still reads the prompt shape older codex versions wrote', async () => {
|
||||
await writeRollout('11111111-1111-7111-8111-111111111111', [
|
||||
sessionMeta({ id: '11111111-1111-7111-8111-111111111111', cwd: '/repo/two' }),
|
||||
legacyUserMessage('$pr-review-comment-fixer 349'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('$pr-review-comment-fixer 349');
|
||||
});
|
||||
|
||||
it('skips injected context when only the fallback shape is present', async () => {
|
||||
await writeRollout('22222222-2222-7222-8222-222222222222', [
|
||||
sessionMeta({ id: '22222222-2222-7222-8222-222222222222', cwd: '/repo/three' }),
|
||||
responseItemUser('# AGENTS.md instructions for /repo/three\n<INSTRUCTIONS> ...'),
|
||||
responseItemUser('<environment_context>cwd=/repo/three</environment_context>'),
|
||||
responseItemUser('Replace PanicOnError with Require().NoError'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('Replace PanicOnError with Require().NoError');
|
||||
});
|
||||
|
||||
it('prefers a real user row over the injection-prone fallback', async () => {
|
||||
await writeRollout('33333333-3333-7333-8333-333333333333', [
|
||||
sessionMeta({ id: '33333333-3333-7333-8333-333333333333', cwd: '/repo/four' }),
|
||||
responseItemUser('Some earlier response_item row'),
|
||||
itemCompletedUser('The prompt the user actually typed'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('The prompt the user actually typed');
|
||||
});
|
||||
|
||||
it('leaves out sub-agent threads, which nobody resumes', async () => {
|
||||
await writeRollout('44444444-4444-7444-8444-444444444444', [
|
||||
sessionMeta({ id: '44444444-4444-7444-8444-444444444444', cwd: '/repo/five' }),
|
||||
itemCompletedUser('a real conversation'),
|
||||
]);
|
||||
await writeRollout('55555555-5555-7555-8555-555555555555', [
|
||||
sessionMeta({ id: '55555555-5555-7555-8555-555555555555', cwd: '/repo/five', threadSource: 'subagent' }),
|
||||
itemCompletedUser('work codex gave itself'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.sessionId)).toEqual(['44444444-4444-7444-8444-444444444444']);
|
||||
});
|
||||
|
||||
it('reports the most recent prompt as well as the first', async () => {
|
||||
await writeRollout('66666666-6666-7666-8666-666666666666', [
|
||||
sessionMeta({ id: '66666666-6666-7666-8666-666666666666', cwd: '/repo/six' }),
|
||||
itemCompletedUser('the opening question'),
|
||||
itemCompletedUser('a follow-up'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('the opening question');
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('orders rows newest first', async () => {
|
||||
await writeRollout(
|
||||
'77777777-7777-7777-8777-777777777777',
|
||||
[sessionMeta({ id: '77777777-7777-7777-8777-777777777777', cwd: '/repo/old' }), itemCompletedUser('older')],
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
);
|
||||
await writeRollout(
|
||||
'88888888-8888-7888-8888-888888888888',
|
||||
[sessionMeta({ id: '88888888-8888-7888-8888-888888888888', cwd: '/repo/new' }), itemCompletedUser('newer')],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.workingDir)).toEqual(['/repo/new', '/repo/old']);
|
||||
});
|
||||
|
||||
it('ignores a file too short to hold a session_meta line', async () => {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'rollout-2026-09-02T09-05-37-short.jsonl'), '{}\n', 'utf-8');
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a rollout whose lines are malformed', async () => {
|
||||
await writeRollout('99999999-9999-7999-8999-999999999999', [
|
||||
sessionMeta({ id: '99999999-9999-7999-8999-999999999999', cwd: '/repo/seven' }),
|
||||
'{not json at all',
|
||||
itemCompletedUser('still found me'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('still found me');
|
||||
});
|
||||
|
||||
it('reports the originator, which is how a fresh pane finds its own rollout', async () => {
|
||||
await writeRollout('bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb', [
|
||||
sessionMeta({
|
||||
id: 'bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb',
|
||||
cwd: '/repo/nine',
|
||||
originator: 'codeman_2f1c9a44-1111-2222-3333-444455556666',
|
||||
}),
|
||||
itemCompletedUser('hello'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].originator).toBe('codeman_2f1c9a44-1111-2222-3333-444455556666');
|
||||
});
|
||||
|
||||
it('drops a rollout that records no working directory', async () => {
|
||||
// Emitting workingDir: '' would make a click post an empty directory.
|
||||
await writeRollout('cccccccc-cccc-7ccc-8ccc-cccccccccccc', [
|
||||
sessionMeta({ id: 'cccccccc-cccc-7ccc-8ccc-cccccccccccc' }),
|
||||
itemCompletedUser('nowhere to resume into'),
|
||||
]);
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks up a prompt written after an earlier scan saw none', async () => {
|
||||
// The bug this pins: the identity cache was written as soon as the thread id
|
||||
// was known, but codex writes the first UserMessage only when the user
|
||||
// submits. Any scan in that window — the home screen, the command palette,
|
||||
// the search-index refresh — pinned `firstPrompt: undefined` until restart.
|
||||
const id = 'dddddddd-dddd-7ddd-8ddd-dddddddddddd';
|
||||
const path = await writeRollout(id, [sessionMeta({ id, cwd: '/repo/ten' })]);
|
||||
|
||||
const before = await scanCodexSessionsHistory();
|
||||
expect(before).toHaveLength(1);
|
||||
expect(before[0].firstPrompt).toBeUndefined();
|
||||
|
||||
await appendFile(path, itemCompletedUser('the prompt, typed a moment later') + '\n', 'utf-8');
|
||||
|
||||
const after = await scanCodexSessionsHistory();
|
||||
expect(after[0].firstPrompt).toBe('the prompt, typed a moment later');
|
||||
});
|
||||
|
||||
it('does not spend the lastPrompt budget on rollouts it never returns', async () => {
|
||||
// The budget used to count file index, so a store whose newest files are all
|
||||
// sub-agent threads exhausted it before the first row that needed it.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writeRollout(
|
||||
`eeeeeeee-eeee-7eee-8eee-00000000000${i}`,
|
||||
[
|
||||
sessionMeta({ id: `eeeeeeee-eeee-7eee-8eee-00000000000${i}`, cwd: '/repo/sub', threadSource: 'subagent' }),
|
||||
itemCompletedUser('subagent work'),
|
||||
],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
}
|
||||
await writeRollout(
|
||||
'ffffffff-ffff-7fff-8fff-ffffffffffff',
|
||||
[
|
||||
sessionMeta({ id: 'ffffffff-ffff-7fff-8fff-ffffffffffff', cwd: '/repo/real' }),
|
||||
itemCompletedUser('opening'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
],
|
||||
new Date('2026-09-04T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('collapses a long prompt to a single capped line', async () => {
|
||||
await writeRollout('aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', [
|
||||
sessionMeta({ id: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', cwd: '/repo/eight' }),
|
||||
itemCompletedUser('line one\nline two\n' + 'y'.repeat(500)),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).not.toContain('\n');
|
||||
expect(rows[0].firstPrompt!.length).toBeLessThanOrEqual(201);
|
||||
expect(rows[0].firstPrompt!.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexThreadBySessionId', () => {
|
||||
const row = (sessionId: string, originator?: string) =>
|
||||
({ sessionId, originator, workingDir: '/w', sizeBytes: 1, lastModified: '2026-09-02T00:00:00.000Z' }) as never;
|
||||
|
||||
it('maps a Codeman-spawned pane to the thread it is writing', () => {
|
||||
const map = codexThreadBySessionId([row('thread-a', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-a');
|
||||
});
|
||||
|
||||
it('ignores a rollout codex started on its own', () => {
|
||||
expect(codexThreadBySessionId([row('thread-a', 'codex-tui')]).size).toBe(0);
|
||||
expect(codexThreadBySessionId([row('thread-a', undefined)]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the newest rollout when a pane has several', () => {
|
||||
// `/new` inside the codex TUI leaves the pane's originator on more than one
|
||||
// rollout; the pane is on the most recent, and rows arrive newest-first.
|
||||
const map = codexThreadBySessionId([row('thread-new', 'codeman_sess-1'), row('thread-old', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-new');
|
||||
});
|
||||
});
|
||||
@@ -397,11 +397,13 @@ describe('Session Manager unified list', () => {
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
||||
|
||||
// History row → resume by conversation UUID.
|
||||
// History row → resume by conversation UUID. The trailing `resumeId` is the
|
||||
// CLI's own thread token, which only a non-claude transcript scanner sets;
|
||||
// a Claude row carries none, so it arrives undefined here.
|
||||
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', undefined, undefined);
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
fetchMock = stubFetch('new-session-id');
|
||||
});
|
||||
|
||||
it.each(['codex', 'gemini', 'antigravity'])(
|
||||
it.each(['gemini', 'antigravity'])(
|
||||
'does NOT retire the old row for %s (no continuation is wired for it)',
|
||||
async (mode) => {
|
||||
const app = makeApp();
|
||||
@@ -104,6 +104,44 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
}
|
||||
);
|
||||
|
||||
// codex continues only when the row carried its thread id. A row without one
|
||||
// is a live session's row, whose sessionId is Codeman's own uuid — sending
|
||||
// THAT to `codex resume` asks for a thread that does not exist, so it must
|
||||
// stay a fresh session and must not retire the row it came from.
|
||||
it('does NOT continue or retire a codex row that carries no resumeId', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'codeman-uuid', '/repo', 'w1-repo', 'codex');
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({ mode: 'codex' });
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resumes a codex row by the thread id the row carried', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(
|
||||
app,
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7',
|
||||
'/repo',
|
||||
'w1-repo',
|
||||
'codex',
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7'
|
||||
);
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: '01a060f0-0361-7f91-abde-b283020db0d7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a resumeId on a row that is not codex', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'gemini', 'some-thread-id');
|
||||
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opencode', 'openCodeConfig'],
|
||||
['pi', 'piConfig'],
|
||||
|
||||
@@ -14,6 +14,94 @@ import {
|
||||
} from '../../src/services/unified-session-service.js';
|
||||
|
||||
describe('mergeUnifiedSessions', () => {
|
||||
// A codex conversation showing twice is worse than cosmetic: the stale PAST row
|
||||
// still resumes, so clicking it starts a SECOND `codex resume` on a thread
|
||||
// already open in another pane. Both folds below are what prevent that.
|
||||
it('folds a RESUMED codex session into its own rollout row', () => {
|
||||
// Session sets claudeSessionId from codexConfig.resumeSessionId, so the live
|
||||
// row already names the thread the rollout is keyed by.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'codex-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect([...merged[0].sources].sort()).toEqual(['history', 'live']);
|
||||
});
|
||||
|
||||
it('folds a FRESH codex session once its rollout has been matched by originator', () => {
|
||||
// A fresh pane knows no thread id, so gatherUnifiedInputs() stamps one onto
|
||||
// the live row from session_meta.originator (see codexThreadBySessionId).
|
||||
// This is that stamped row.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'busy', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
persisted: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'fresh-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 900,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'fresh-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect(merged[0].status).toBe('busy');
|
||||
});
|
||||
|
||||
it('leaves an unrelated codex rollout as its own row', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'thread-one' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'thread-two',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'thread-two',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged.map((m) => m.sessionId).sort()).toEqual(['codeman-uuid', 'thread-two']);
|
||||
});
|
||||
|
||||
it("carries a transcript row's own resume token, and stamps none on a live row", () => {
|
||||
// codex names a thread by an id in its rollout, not by Codeman's session id.
|
||||
// The scanner sets `resumeId`; a live session never does, which is what stops
|
||||
// a resume from asking codex for a thread whose id is really Codeman's uuid.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fromTranscript = merged.find((m) => m.sessionId === 'codex-thread-id');
|
||||
const fromLive = merged.find((m) => m.sessionId === 'codeman-uuid');
|
||||
expect(fromTranscript?.resumeId).toBe('codex-thread-id');
|
||||
expect(fromTranscript?.mode).toBe('codex');
|
||||
expect(fromLive?.resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dedupes the same sessionId across live + persisted into one item', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 's1', status: 'working', isWorking: true }],
|
||||
|
||||
@@ -100,9 +100,11 @@ describe('Session claude conversation chain', () => {
|
||||
// before this existed. Every assignment built from the launch-id fallback
|
||||
// must therefore carry `restoredConversation` first.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/session.ts'), 'utf8');
|
||||
const fallbackAssignments = source.match(
|
||||
/_claudeSessionId =\s*\n?\s*[^;]*?_resumeSessionId \|\| this\._ompConfig\?\.resumeSessionId \|\| this\.id;/g
|
||||
);
|
||||
// The tail of the chain grows as each CLI gains a resume alias of its own
|
||||
// (omp, then codex), so the pattern pins the two ends and lets the middle
|
||||
// widen. A `[^;]` run cannot cross a statement boundary, so each match is
|
||||
// still one assignment.
|
||||
const fallbackAssignments = source.match(/_claudeSessionId =[^;]*?_resumeSessionId[^;]*?this\.id;/g);
|
||||
expect(fallbackAssignments).not.toBeNull();
|
||||
expect(fallbackAssignments!.length).toBeGreaterThanOrEqual(2);
|
||||
for (const assignment of fallbackAssignments!) {
|
||||
|
||||
Reference in New Issue
Block a user