mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
528 lines
19 KiB
TypeScript
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' }
|
|
]
|
|
})
|
|
})
|
|
|
|
})
|