Files
hapi/hub/src/web/routes/sessions.test.ts
T

528 lines
19 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import { Hono } from 'hono'
import type { Session, SyncEngine } from '../../sync/syncEngine'
import type { WebAppEnv } from '../middleware/auth'
import { createSessionsRoutes } from './sessions'
function createSession(overrides?: Partial<Session>): Session {
const baseMetadata = {
path: '/tmp/project',
host: 'localhost',
flavor: 'codex' as const
}
const base: Session = {
id: 'session-1',
namespace: 'default',
seq: 1,
createdAt: 1,
updatedAt: 1,
active: true,
activeAt: 1,
metadata: baseMetadata,
metadataVersion: 1,
agentState: {
controlledByUser: false,
requests: {},
completedRequests: {}
},
agentStateVersion: 1,
thinking: false,
thinkingAt: 1,
model: 'gpt-5.4',
modelReasoningEffort: null,
effort: null,
permissionMode: 'default',
collaborationMode: 'default'
}
return {
...base,
...overrides,
metadata: overrides?.metadata === undefined
? base.metadata
: overrides.metadata === null
? null
: {
...baseMetadata,
...overrides.metadata
},
agentState: overrides?.agentState === undefined ? base.agentState : overrides.agentState
}
}
function createApp(session: Session, opts?: {
resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }>
listSlashCommands?: SyncEngine['listSlashCommands']
}) {
const applySessionConfigCalls: Array<[string, Record<string, unknown>]> = []
const applySessionConfig = async (sessionId: string, config: Record<string, unknown>) => {
applySessionConfigCalls.push([sessionId, config])
}
const listCodexModelsForSession = async () => ({
success: true,
models: [
{ id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true }
]
})
const listOpencodeModelsForSession = async () => ({
success: true,
availableModels: [
{ modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama (SER8)/EXAONE 4.5 33B Q8' },
{ modelId: 'mlx/qwen3:0.6b', name: 'MLX/Qwen3 0.6B' }
],
currentModelId: 'ollama/exaone:4.5-33b-q8'
})
const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId }))
const engine = {
resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }),
applySessionConfig,
listCodexModelsForSession,
listOpencodeModelsForSession,
resumeSession,
listSlashCommands: opts?.listSlashCommands ?? (async () => ({
success: true,
commands: []
}))
} as Partial<SyncEngine>
const app = new Hono<WebAppEnv>()
app.use('*', async (c, next) => {
c.set('namespace', 'default')
await next()
})
app.route('/api', createSessionsRoutes(() => engine as SyncEngine))
return { app, applySessionConfigCalls }
}
describe('sessions routes', () => {
it('rejects collaboration mode changes for local Codex sessions', async () => {
const session = createSession({
agentState: {
controlledByUser: true,
requests: {},
completedRequests: {}
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/collaboration-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'plan' })
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Collaboration mode can only be changed for remote Codex sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('rejects collaboration mode changes for non-Codex sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/collaboration-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'plan' })
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Collaboration mode is only supported for Codex sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('applies collaboration mode changes for remote Codex sessions', async () => {
const { app, applySessionConfigCalls } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/collaboration-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'plan' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { collaborationMode: 'plan' }]
])
})
it('rejects model reasoning effort changes for non-Codex sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model-reasoning-effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ modelReasoningEffort: 'high' })
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Model reasoning effort is only supported for Codex sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('rejects model reasoning effort changes for local Codex sessions', async () => {
const session = createSession({
agentState: {
controlledByUser: true,
requests: {},
completedRequests: {}
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model-reasoning-effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ modelReasoningEffort: 'high' })
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Model reasoning effort can only be changed for remote Codex sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('applies model reasoning effort changes for remote Codex sessions', async () => {
const { app, applySessionConfigCalls } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/model-reasoning-effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ modelReasoningEffort: 'xhigh' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { modelReasoningEffort: 'xhigh' }]
])
})
it('applies model changes for remote Codex sessions', async () => {
const { app, applySessionConfigCalls } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/model', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'gpt-5.5' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { model: 'gpt-5.5' }]
])
})
it('rejects model changes for local Codex sessions', async () => {
const session = createSession({
agentState: {
controlledByUser: true,
requests: {},
completedRequests: {}
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'gpt-5.5' })
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Model selection can only be changed for remote Codex sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('applies model changes for OpenCode sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'opencode'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'ollama/exaone:4.5-33b-q8' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { model: 'ollama/exaone:4.5-33b-q8' }]
])
})
it('applies model changes for Gemini sessions (regression: opencode addition does not break Gemini)', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'gemini'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'gemini-2.5-pro' })
})
expect(response.status).toBe(200)
expect(applySessionConfigCalls).toEqual([
['session-1', { model: 'gemini-2.5-pro' }]
])
})
it('rejects model changes for Cursor sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/model', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'sonnet' })
})
expect(response.status).toBe(400)
expect(applySessionConfigCalls).toEqual([])
})
it('rejects effort changes for non-Claude sessions', async () => {
const { app, applySessionConfigCalls } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ effort: 'high' })
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Effort selection is only supported for Claude sessions'
})
expect(applySessionConfigCalls).toEqual([])
})
it('applies effort changes for Claude sessions', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude'
}
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/effort', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ effort: 'max' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { effort: 'max' }]
])
})
it('returns Codex models for active Codex sessions', async () => {
const { app } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/codex-models')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
models: [
{ id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true }
]
})
})
it('returns OpenCode models for active OpenCode sessions', async () => {
const session = createSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }
})
const { app } = createApp(session)
const response = await app.request('/api/sessions/session-1/opencode-models')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
availableModels: [
{ modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama (SER8)/EXAONE 4.5 33B Q8' },
{ modelId: 'mlx/qwen3:0.6b', name: 'MLX/Qwen3 0.6B' }
],
currentModelId: 'ollama/exaone:4.5-33b-q8'
})
})
it('rejects opencode-models for non-OpenCode sessions', async () => {
const { app } = createApp(createSession())
const response = await app.request('/api/sessions/session-1/opencode-models')
expect(response.status).toBe(400)
})
it('applies permission mode changes for inactive sessions', async () => {
const session = createSession({
active: false,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' }
})
const { app, applySessionConfigCalls } = createApp(session)
const response = await app.request('/api/sessions/session-1/permission-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'bypassPermissions' })
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ ok: true })
expect(applySessionConfigCalls).toEqual([
['session-1', { permissionMode: 'bypassPermissions' }]
])
})
it('rejects unsupported permission mode for flavor via resume body', async () => {
const session = createSession({
active: false,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex' }
})
const { app } = createApp(session)
const response = await app.request('/api/sessions/session-1/resume', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ permissionMode: 'bypassPermissions' })
})
expect(response.status).toBe(400)
})
it('passes permissionMode from resume body to resumeSession', async () => {
const session = createSession({
active: false,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' }
})
let capturedResumeOpts: { permissionMode?: string } | undefined
const { app } = createApp(session, {
resumeSession: async (sessionId, _namespace, resumeOpts) => {
capturedResumeOpts = resumeOpts
return { type: 'success', sessionId }
}
})
const response = await app.request('/api/sessions/session-1/resume', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ permissionMode: 'bypassPermissions' })
})
expect(response.status).toBe(200)
expect(capturedResumeOpts).toEqual({ permissionMode: 'bypassPermissions' })
})
it('falls back to metadata slash commands when RPC listing fails', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude',
slashCommands: ['help', 'memory', 'status']
}
})
const { app } = createApp(session, {
listSlashCommands: async () => {
throw new Error('RPC unavailable')
}
})
const response = await app.request('/api/sessions/session-1/slash-commands')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
commands: [
{ name: 'help', source: 'builtin' },
{ name: 'memory', source: 'builtin' },
{ name: 'status', source: 'builtin' }
]
})
})
it('merges RPC and metadata slash commands without hiding built-ins', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude',
slashCommands: ['help', 'memory']
}
})
const { app } = createApp(session, {
listSlashCommands: async () => ({
success: true,
commands: [
{ name: 'clear', source: 'builtin' },
{ name: 'project-only', source: 'project', content: 'Project prompt' }
]
})
})
const response = await app.request('/api/sessions/session-1/slash-commands')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
commands: [
{ name: 'help', source: 'builtin' },
{ name: 'memory', source: 'builtin' },
{ name: 'clear', source: 'builtin' },
{ name: 'project-only', source: 'project', content: 'Project prompt' }
]
})
})
})