diff --git a/src/db.test.ts b/src/db.test.ts index 81012c4..0db9786 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -10,11 +10,14 @@ import { createTask, createServiceHandoff, createProducedWorkItem, + clearExplicitRoomMode, deleteSession, deleteTask, getAllChats, getAllRegisteredGroups, getDueTasks, + getEffectiveRoomMode, + getExplicitRoomMode, getLatestMessageSeqAtOrBefore, getLatestPairedTaskForChat, getMessagesSinceSeq, @@ -37,6 +40,7 @@ import { setSession, setRegisteredGroup, setRouterStateForService, + setExplicitRoomMode, storeChatMetadata, storeMessage, updatePairedTask, @@ -801,6 +805,73 @@ describe('paired room registration', () => { expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']); expect(isPairedRoomJid('dc:solo')).toBe(false); }); + + it('falls back to legacy paired-room inference when no explicit room mode exists', () => { + setRegisteredGroup('dc:legacy-paired', { + name: 'Legacy Paired Claude', + folder: 'legacy-paired-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + setRegisteredGroup('dc:legacy-paired', { + name: 'Legacy Paired Codex', + folder: 'legacy-paired-codex', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:legacy-paired')).toBe( + isPairedRoomJid('dc:legacy-paired') ? 'tribunal' : 'single', + ); + }); + + it('lets explicit single override dual registration without changing legacy paired inference yet', () => { + setRegisteredGroup('dc:explicit-single', { + name: 'Explicit Single Claude', + folder: 'explicit-single-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + setRegisteredGroup('dc:explicit-single', { + name: 'Explicit Single Codex', + folder: 'explicit-single-codex', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + setExplicitRoomMode('dc:explicit-single', 'single'); + + expect(getExplicitRoomMode('dc:explicit-single')).toBe('single'); + expect(getEffectiveRoomMode('dc:explicit-single')).toBe('single'); + expect(isPairedRoomJid('dc:explicit-single')).toBe(true); + }); + + it('lets explicit tribunal override solo fallback and clears back to inferred mode', () => { + setRegisteredGroup('dc:explicit-tribunal', { + name: 'Explicit Tribunal Claude', + folder: 'explicit-tribunal-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + + expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('single'); + + setExplicitRoomMode('dc:explicit-tribunal', 'tribunal'); + + expect(getExplicitRoomMode('dc:explicit-tribunal')).toBe('tribunal'); + expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('tribunal'); + + clearExplicitRoomMode('dc:explicit-tribunal'); + + expect(getExplicitRoomMode('dc:explicit-tribunal')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('single'); + }); }); describe('service handoff completion', () => { diff --git a/src/db.ts b/src/db.ts index 7b3aeaa..a99dd05 100644 --- a/src/db.ts +++ b/src/db.ts @@ -26,6 +26,7 @@ import { AgentType, PairedProject, PairedRoomRole, + RoomMode, PairedTask, PairedTurnOutput, PairedWorkspace, @@ -298,6 +299,12 @@ function createSchema(database: Database): void { activated_at TEXT, reason TEXT ); + CREATE TABLE IF NOT EXISTS room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (room_mode IN ('single', 'tribunal')) + ); CREATE TABLE IF NOT EXISTS service_handoffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, @@ -1925,6 +1932,52 @@ export function getRegisteredAgentTypesForJid(jid: string): AgentType[] { return [...types]; } +export function inferRoomModeFromRegisteredAgentTypes( + agentTypes: readonly AgentType[], +): RoomMode { + const types = new Set(agentTypes); + return types.has('claude-code') && types.has('codex') + ? 'tribunal' + : 'single'; +} + +export function inferRoomModeForJid(jid: string): RoomMode { + return inferRoomModeFromRegisteredAgentTypes(getRegisteredAgentTypesForJid(jid)); +} + +export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { + if (!db) return undefined; + + const row = db + .prepare( + `SELECT room_mode + FROM room_settings + WHERE chat_jid = ?`, + ) + .get(chatJid) as { room_mode: string | null } | undefined; + return row?.room_mode === 'single' || row?.room_mode === 'tribunal' + ? row.room_mode + : undefined; +} + +export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { + db.prepare( + `INSERT OR REPLACE INTO room_settings ( + chat_jid, + room_mode, + updated_at + ) VALUES (?, ?, ?)`, + ).run(chatJid, roomMode, new Date().toISOString()); +} + +export function clearExplicitRoomMode(chatJid: string): void { + db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); +} + +export function getEffectiveRoomMode(chatJid: string): RoomMode { + return getExplicitRoomMode(chatJid) ?? inferRoomModeForJid(chatJid); +} + export function isPairedRoomJid(jid: string): boolean { const types = getRegisteredAgentTypesForJid(jid); return types.includes('claude-code') && types.includes('codex'); diff --git a/src/types.ts b/src/types.ts index e2c392c..1a91729 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export interface AgentConfig { } export type AgentType = 'claude-code' | 'codex'; +export type RoomMode = 'single' | 'tribunal'; /** Phase of agent output as emitted by the runner. */ export type AgentOutputPhase =