Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions hub/src/sync/permissionModePersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'bun:test'
import { Store } from '../store'
import { SyncEngine } from './syncEngine'

function createEngine(store?: Store): SyncEngine {
const engine = new SyncEngine(
store ?? new Store(':memory:'),
{
of: () => ({
to: () => ({
emit: () => {}
})
})
} as never,
{} as never,
{
broadcast: () => {}
} as never
)
engine.stop()
return engine
}

describe('permission mode persistence', () => {
it('restores saved permission mode after reloading sessions from the store', () => {
const store = new Store(':memory:')
const engine = createEngine(store)

const session = engine.getOrCreateSession(
'permission-mode-persist',
{ path: '/tmp/project', host: 'localhost' },
{ requests: {}, completedRequests: {} },
'default'
)

engine.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
permissionMode: 'yolo'
})

const reloadedEngine = createEngine(store)
const reloadedSession = reloadedEngine.getSession(session.id)

expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('yolo')
expect(reloadedSession?.permissionMode).toBe('yolo')
})

it('reapplies the previous permission mode when resuming an archived session', async () => {
const engine = createEngine()

const machine = engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: machine.id, time: Date.now() })

const session = engine.getOrCreateSession(
'resume-permission-mode',
{
path: '/tmp/project',
host: 'localhost',
machineId: machine.id,
flavor: 'codex',
codexSessionId: 'resume-token',
preferredPermissionMode: 'yolo'
},
{ requests: {}, completedRequests: {} },
'default'
)

const calls: Array<{ type: 'spawn' } | { type: 'config'; sessionId: string; permissionMode?: string }> = []
const spawnSession = async () => {
calls.push({ type: 'spawn' })
return { type: 'success' as const, sessionId: session.id }
}
const requestSessionConfig = async (sessionId: string, config: { permissionMode?: string }) => {
calls.push({ type: 'config', sessionId, permissionMode: config.permissionMode })
return { applied: { permissionMode: config.permissionMode } }
}

;(engine as any).rpcGateway = {
spawnSession,
requestSessionConfig
}
;(engine as any).waitForSessionActive = async () => true

const result = await engine.resumeSession(session.id, 'default')

expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(calls).toContainEqual({ type: 'spawn' })
expect(calls).toContainEqual({ type: 'config', sessionId: session.id, permissionMode: 'yolo' })
})
})
36 changes: 35 additions & 1 deletion hub/src/sync/sessionCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class SessionCache {
thinkingAt: existing?.thinkingAt ?? 0,
todos,
teamState,
permissionMode: existing?.permissionMode,
permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode,
modelMode: existing?.modelMode
}

Expand Down Expand Up @@ -167,6 +167,7 @@ export class SessionCache {
session.thinkingAt = t
if (payload.permissionMode !== undefined) {
session.permissionMode = payload.permissionMode
this.persistPreferredPermissionMode(session, payload.permissionMode)
}
if (payload.modelMode !== undefined) {
session.modelMode = payload.modelMode
Expand Down Expand Up @@ -233,6 +234,7 @@ export class SessionCache {

if (config.permissionMode !== undefined) {
session.permissionMode = config.permissionMode
this.persistPreferredPermissionMode(session, config.permissionMode)
}
if (config.modelMode !== undefined) {
session.modelMode = config.modelMode
Expand Down Expand Up @@ -398,7 +400,39 @@ export class SessionCache {
merged.host = oldObj.host
changed = true
}
if (typeof oldObj.preferredPermissionMode === 'string' && typeof newObj.preferredPermissionMode !== 'string') {
merged.preferredPermissionMode = oldObj.preferredPermissionMode
changed = true
}

return changed ? merged : newMetadata
}

private persistPreferredPermissionMode(session: Session, permissionMode: PermissionMode): void {
const currentMetadata = session.metadata
if (!currentMetadata || currentMetadata.preferredPermissionMode === permissionMode) {
return
}

const nextMetadata = { ...currentMetadata, preferredPermissionMode: permissionMode }
const result = this.store.sessions.updateSessionMetadata(
session.id,
nextMetadata,
session.metadataVersion,
session.namespace,
{ touchUpdatedAt: false }
)

if (result.result === 'error') {
return
}

const parsed = MetadataSchema.safeParse(result.value)
if (!parsed.success) {
return
}

session.metadata = parsed.data
session.metadataVersion = result.version
}
}
10 changes: 10 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export class SyncEngine {
if (!metadata || typeof metadata.path !== 'string') {
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
}
const preferredPermissionMode = session.permissionMode ?? metadata.preferredPermissionMode

const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' || metadata.flavor === 'cursor'
? metadata.flavor
Expand Down Expand Up @@ -388,6 +389,15 @@ export class SyncEngine {
return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' }
}

if (preferredPermissionMode !== undefined) {
try {
await this.applySessionConfig(spawnResult.sessionId, { permissionMode: preferredPermissionMode })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to restore permission mode'
return { type: 'error', message, code: 'resume_failed' }
}
}

if (spawnResult.sessionId !== access.sessionId) {
try {
await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace)
Expand Down
1 change: 1 addition & 0 deletions shared/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const MetadataSchema = z.object({
lifecycleStateSince: z.number().optional(),
archivedBy: z.string().optional(),
archiveReason: z.string().optional(),
preferredPermissionMode: PermissionModeSchema.optional(),
flavor: z.string().nullish(),
worktree: WorktreeMetadataSchema.optional()
})
Expand Down
Loading