Before, every provider stored its opaque continuation id under the single outbound.db key `sdk_session_id`. Flipping a session's agent_provider (e.g. Codex → Claude) meant the new provider read the old provider's id at wake, handed it to its own SDK, and got a "No conversation found" error that cost the user one sacrificed message before the stale-session recovery path cleared the id. This reshapes session_state so continuations are keyed `continuation:<provider>` instead. Consequences: - Per-provider continuations coexist. Flipping Claude → Codex → Claude resumes the Claude thread exactly where it left off, with the intervening Codex thread also still on file. - No provider ever reads another provider's id. Switching costs no sacrificed message and emits no transient error. - Legacy installs are migrated forward on first startup: migrateLegacyContinuation() adopts any pre-existing `sdk_session_id` row into the current provider's slot (best guess — it was whichever provider ran last), then deletes the legacy row unconditionally so it can't poison a future provider's read. runPollLoop now takes providerName alongside the provider instance, and threads it through processQuery to setContinuation on init. Tests: 9 new tests covering set/get isolation across providers, clear-specificity, legacy-adoption, legacy-always-deleted, prefer-existing-slot-over-legacy, and idempotency of a second migration call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
|
|
import { getOutboundDb, initTestSessionDb } from './connection.js';
|
|
import {
|
|
clearContinuation,
|
|
getContinuation,
|
|
migrateLegacyContinuation,
|
|
setContinuation,
|
|
} from './session-state.js';
|
|
|
|
beforeEach(() => {
|
|
initTestSessionDb();
|
|
});
|
|
|
|
function seedLegacy(value: string): void {
|
|
getOutboundDb()
|
|
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
|
.run('sdk_session_id', value, new Date().toISOString());
|
|
}
|
|
|
|
describe('session-state — per-provider continuations', () => {
|
|
test('set/get round-trip, case-insensitive provider key', () => {
|
|
setContinuation('claude', 'claude-conv-1');
|
|
expect(getContinuation('claude')).toBe('claude-conv-1');
|
|
expect(getContinuation('Claude')).toBe('claude-conv-1');
|
|
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
|
|
});
|
|
|
|
test('providers are isolated — switching reads the right slot', () => {
|
|
setContinuation('claude', 'claude-conv-1');
|
|
setContinuation('codex', 'codex-thread-xyz');
|
|
|
|
expect(getContinuation('claude')).toBe('claude-conv-1');
|
|
expect(getContinuation('codex')).toBe('codex-thread-xyz');
|
|
});
|
|
|
|
test('clearContinuation only affects the specified provider', () => {
|
|
setContinuation('claude', 'keep-me');
|
|
setContinuation('codex', 'drop-me');
|
|
|
|
clearContinuation('codex');
|
|
|
|
expect(getContinuation('claude')).toBe('keep-me');
|
|
expect(getContinuation('codex')).toBeUndefined();
|
|
});
|
|
|
|
test('unknown provider returns undefined', () => {
|
|
expect(getContinuation('never-used')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('session-state — legacy migration', () => {
|
|
test('adopts legacy value into current provider when current is empty', () => {
|
|
seedLegacy('old-session-id');
|
|
|
|
const adopted = migrateLegacyContinuation('claude');
|
|
|
|
expect(adopted).toBe('old-session-id');
|
|
expect(getContinuation('claude')).toBe('old-session-id');
|
|
});
|
|
|
|
test('always deletes legacy row regardless of migration outcome', () => {
|
|
seedLegacy('old-session-id');
|
|
setContinuation('claude', 'existing');
|
|
|
|
migrateLegacyContinuation('claude');
|
|
|
|
// After migration the legacy key must be gone, whether or not it was adopted.
|
|
// A subsequent migration for a different provider must not see it.
|
|
const resultAfterSecondCall = migrateLegacyContinuation('codex');
|
|
expect(resultAfterSecondCall).toBeUndefined();
|
|
});
|
|
|
|
test('prefers existing current-provider slot over legacy', () => {
|
|
seedLegacy('legacy-value');
|
|
setContinuation('claude', 'claude-value');
|
|
|
|
const result = migrateLegacyContinuation('claude');
|
|
|
|
expect(result).toBe('claude-value');
|
|
expect(getContinuation('claude')).toBe('claude-value');
|
|
});
|
|
|
|
test('no legacy row — returns current provider value (possibly undefined)', () => {
|
|
expect(migrateLegacyContinuation('claude')).toBeUndefined();
|
|
|
|
setContinuation('codex', 'codex-value');
|
|
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
|
|
});
|
|
|
|
test('migration is idempotent on a second call (legacy already gone)', () => {
|
|
seedLegacy('once');
|
|
|
|
const first = migrateLegacyContinuation('claude');
|
|
expect(first).toBe('once');
|
|
|
|
const second = migrateLegacyContinuation('claude');
|
|
expect(second).toBe('once');
|
|
});
|
|
});
|