mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop chat can fall back to persisted transcript estimates when live inspection context is unavailable. That path previously only used built-in model windows, so custom provider settings such as MiniMax-M3 with a 1,000,000-token window still displayed the default 200,000-token estimate. Resolve transcript context windows from the session or active provider runtime env first, then fall back to built-ins. Constraint: Provider model context windows are stored in provider runtime env, not always in the server process env. Rejected: Change the built-in MiniMax-M3 window to 1,000,000 | upstream preset correctly keeps MiniMax-M3 at 204,800 by default, while user overrides must remain provider-specific. Confidence: high Scope-risk: moderate Directive: Keep transcript estimates aligned with session/provider runtime env before consulting built-in model defaults. Tested: git diff --check Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "active provider model context windows|Sonnet 4.6 transcript usage|low-trust media" Tested: bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/provider-runtime-env.test.ts src/server/__tests__/providers.test.ts src/utils/__tests__/context.test.ts src/utils/__tests__/contextBudget.test.ts Tested: bun test src/server/__tests__/conversations.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/provider-runtime-env.test.ts src/utils/__tests__/context.test.ts src/utils/__tests__/contextBudget.test.ts Not-tested: bun run check:server blocked before execution by expired quarantine entries: server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
3437 lines
117 KiB
TypeScript
3437 lines
117 KiB
TypeScript
/**
|
|
* Tests for ConversationService and WebSocket chat integration
|
|
*
|
|
* ConversationService 管理 CLI 子进程的生命周期。
|
|
* WebSocket 集成测试验证消息从客户端经过服务端到达 CLI 的完整流转。
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
import * as os from 'os'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js'
|
|
import { SessionService, sessionService } from '../services/sessionService.js'
|
|
import { ProviderService } from '../services/providerService.js'
|
|
|
|
async function rmWithRetry(targetPath: string): Promise<void> {
|
|
const attempts = process.platform === 'win32' ? 5 : 1
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
try {
|
|
await fs.rm(targetPath, { recursive: true, force: true })
|
|
return
|
|
} catch (error) {
|
|
if (
|
|
attempt === attempts - 1 ||
|
|
!['EBUSY', 'EPERM', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code || '')
|
|
) {
|
|
throw error
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ConversationService unit tests
|
|
// ============================================================================
|
|
|
|
describe('ConversationService', () => {
|
|
it('should report no session for unknown ID', () => {
|
|
const svc = new ConversationService()
|
|
const sid = crypto.randomUUID()
|
|
expect(svc.hasSession(sid)).toBe(false)
|
|
})
|
|
|
|
it('should track active sessions as empty initially', () => {
|
|
const svc = new ConversationService()
|
|
expect(svc.getActiveSessions()).toEqual([])
|
|
})
|
|
|
|
it('should block startup after a session is deleted during prewarm', async () => {
|
|
const svc = new ConversationService()
|
|
const sid = crypto.randomUUID()
|
|
|
|
svc.markSessionDeleted(sid)
|
|
|
|
try {
|
|
await svc.startSession(sid, process.cwd(), 'ws://127.0.0.1:1/sdk/test')
|
|
throw new Error('expected startSession to reject')
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ConversationStartupError)
|
|
expect((error as ConversationStartupError).code).toBe('SESSION_DELETED')
|
|
}
|
|
})
|
|
|
|
it('should return false when sending message to non-existent session', async () => {
|
|
const svc = new ConversationService()
|
|
const result = await svc.sendMessage('no-such-session', 'hello')
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should return false when responding to permission for non-existent session', () => {
|
|
const svc = new ConversationService()
|
|
const result = svc.respondToPermission('no-such-session', 'req-1', true)
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should not queue control requests before the SDK socket connects', async () => {
|
|
const svc = new ConversationService()
|
|
const sid = crypto.randomUUID()
|
|
const sent: unknown[] = []
|
|
const session: any = {
|
|
proc: { kill() {}, exited: Promise.resolve(0) },
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
permissionMode: 'default',
|
|
sdkToken: 'token',
|
|
sdkSocket: null,
|
|
pendingOutbound: [],
|
|
startupPending: false,
|
|
startupExitCode: null,
|
|
stdoutLines: [],
|
|
stderrLines: [],
|
|
outputDrain: Promise.resolve(),
|
|
sdkMessages: [],
|
|
initMessage: null,
|
|
pendingPermissionRequests: new Map(),
|
|
}
|
|
;(svc as any).sessions.set(sid, session)
|
|
|
|
const request = svc.requestControl(sid, { subtype: 'get_context_usage' }, 1_000)
|
|
await new Promise((resolve) => setTimeout(resolve, 75))
|
|
|
|
expect(session.pendingOutbound).toHaveLength(0)
|
|
expect(sent).toHaveLength(0)
|
|
|
|
session.sdkSocket = {
|
|
send(data: string) {
|
|
sent.push(JSON.parse(data))
|
|
},
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 75))
|
|
expect(session.pendingOutbound).toHaveLength(0)
|
|
expect(sent).toHaveLength(1)
|
|
|
|
const requestId = (sent[0] as any).request_id
|
|
for (const callback of [...session.outputCallbacks]) {
|
|
callback({
|
|
type: 'control_response',
|
|
response: {
|
|
subtype: 'success',
|
|
request_id: requestId,
|
|
response: { ok: true },
|
|
},
|
|
})
|
|
}
|
|
|
|
await expect(request).resolves.toEqual({ ok: true })
|
|
})
|
|
|
|
it('should forward suggested permission updates for allow-for-session decisions', () => {
|
|
const svc = new ConversationService()
|
|
const sent: unknown[] = []
|
|
|
|
;(svc as any).sessions.set('session-1', {
|
|
proc: null,
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
sdkToken: 'token',
|
|
sdkSocket: {
|
|
send(data: string) {
|
|
sent.push(JSON.parse(data))
|
|
},
|
|
},
|
|
pendingOutbound: [],
|
|
stderrLines: [],
|
|
sdkMessages: [],
|
|
pendingPermissionRequests: new Map([
|
|
['req-1', {
|
|
toolName: 'Bash',
|
|
input: { command: 'ls src' },
|
|
permissionSuggestions: [
|
|
{
|
|
type: 'addRules',
|
|
rules: [{ toolName: 'Bash', ruleContent: 'ls src' }],
|
|
behavior: 'allow',
|
|
destination: 'localSettings',
|
|
},
|
|
],
|
|
}],
|
|
]),
|
|
})
|
|
|
|
const result = svc.respondToPermission('session-1', 'req-1', true, 'always')
|
|
|
|
expect(result).toBe(true)
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toMatchObject({
|
|
type: 'control_response',
|
|
response: {
|
|
response: {
|
|
behavior: 'allow',
|
|
updatedPermissions: [
|
|
{
|
|
type: 'addRules',
|
|
rules: [{ toolName: 'Bash', ruleContent: 'ls src' }],
|
|
behavior: 'allow',
|
|
destination: 'session',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should send set_permission_mode requests to active sessions', () => {
|
|
const svc = new ConversationService()
|
|
const sent: unknown[] = []
|
|
|
|
;(svc as any).sessions.set('session-2', {
|
|
proc: null,
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
sdkToken: 'token',
|
|
sdkSocket: {
|
|
send(data: string) {
|
|
sent.push(JSON.parse(data))
|
|
},
|
|
},
|
|
pendingOutbound: [],
|
|
stderrLines: [],
|
|
sdkMessages: [],
|
|
pendingPermissionRequests: new Map(),
|
|
})
|
|
|
|
const result = svc.setPermissionMode('session-2', 'acceptEdits')
|
|
|
|
expect(result).toBe(true)
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toMatchObject({
|
|
type: 'control_request',
|
|
request: {
|
|
subtype: 'set_permission_mode',
|
|
mode: 'acceptEdits',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should not inject a desktop-specific ask override in default permission mode', () => {
|
|
const svc = new ConversationService()
|
|
expect((svc as any).getPermissionArgs('default', false)).toEqual([
|
|
'--permission-mode',
|
|
'default',
|
|
])
|
|
})
|
|
|
|
it('should pass disabled thinking to the CLI runtime args', () => {
|
|
const svc = new ConversationService()
|
|
expect((svc as any).getRuntimeArgs({
|
|
model: 'deepseek-v4-pro',
|
|
effort: 'medium',
|
|
thinking: 'disabled',
|
|
})).toEqual([
|
|
'--model',
|
|
'deepseek-v4-pro',
|
|
'--effort',
|
|
'medium',
|
|
'--thinking',
|
|
'disabled',
|
|
])
|
|
})
|
|
|
|
it('should send thinking token controls to active CLI sessions', () => {
|
|
const svc = new ConversationService() as any
|
|
const sent: string[] = []
|
|
svc.sessions.set('session-thinking-control', {
|
|
sdkSocket: { send: (data: string) => sent.push(data) },
|
|
pendingOutbound: [],
|
|
})
|
|
|
|
expect(svc.setMaxThinkingTokens('session-thinking-control', 0)).toBe(true)
|
|
expect(svc.setMaxThinkingTokens('session-thinking-control', null)).toBe(true)
|
|
expect(svc.setMaxThinkingTokensForActiveSessions(0)).toBe(1)
|
|
|
|
expect(sent.map((line) => JSON.parse(line).request)).toEqual([
|
|
{
|
|
subtype: 'set_max_thinking_tokens',
|
|
max_thinking_tokens: 0,
|
|
},
|
|
{
|
|
subtype: 'set_max_thinking_tokens',
|
|
max_thinking_tokens: null,
|
|
},
|
|
{
|
|
subtype: 'set_max_thinking_tokens',
|
|
max_thinking_tokens: 0,
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should return false when sending interrupt to non-existent session', () => {
|
|
const svc = new ConversationService()
|
|
const result = svc.sendInterrupt('no-such-session')
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should not throw when stopping non-existent session', () => {
|
|
const svc = new ConversationService()
|
|
expect(() => svc.stopSession('no-such-session')).not.toThrow()
|
|
})
|
|
|
|
it('should not throw when registering callback for non-existent session', () => {
|
|
const svc = new ConversationService()
|
|
expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow()
|
|
})
|
|
|
|
it('should ignore stale process exits after a session restarts', async () => {
|
|
const svc = new ConversationService()
|
|
const oldProc = { pid: 1 } as any
|
|
const newProc = { pid: 2 } as any
|
|
|
|
;(svc as any).sessions.set('session-restart', {
|
|
proc: newProc,
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
permissionMode: 'bypassPermissions',
|
|
sdkToken: 'token',
|
|
sdkSocket: null,
|
|
pendingOutbound: [],
|
|
stderrLines: [],
|
|
sdkMessages: [],
|
|
pendingPermissionRequests: new Map(),
|
|
})
|
|
|
|
await (svc as any).handleProcessExit('session-restart', oldProc, 143)
|
|
expect(svc.hasSession('session-restart')).toBe(true)
|
|
|
|
await (svc as any).handleProcessExit('session-restart', newProc, 0)
|
|
expect(svc.hasSession('session-restart')).toBe(false)
|
|
})
|
|
|
|
it('should retain SDK init metadata after recent message trimming', () => {
|
|
const svc = new ConversationService()
|
|
|
|
;(svc as any).sessions.set('session-init-retention', {
|
|
proc: { pid: 1 },
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
permissionMode: 'default',
|
|
sdkToken: 'token',
|
|
sdkSocket: null,
|
|
pendingOutbound: [],
|
|
stderrLines: [],
|
|
sdkMessages: [],
|
|
initMessage: null,
|
|
pendingPermissionRequests: new Map(),
|
|
})
|
|
|
|
;(svc as any).handleSdkPayload('session-init-retention', JSON.stringify({
|
|
type: 'system',
|
|
subtype: 'init',
|
|
model: 'mock-opus',
|
|
claude_code_version: 'test-version',
|
|
slash_commands: ['help', 'context'],
|
|
}))
|
|
|
|
for (let i = 0; i < 45; i++) {
|
|
;(svc as any).handleSdkPayload('session-init-retention', JSON.stringify({
|
|
type: 'stream_event',
|
|
event: { type: 'message_delta', index: i },
|
|
}))
|
|
}
|
|
|
|
expect(svc.getRecentSdkMessages('session-init-retention').some((message) => message.subtype === 'init')).toBe(false)
|
|
expect(svc.getSessionInitMessage('session-init-retention')).toMatchObject({
|
|
model: 'mock-opus',
|
|
claude_code_version: 'test-version',
|
|
slash_commands: ['help', 'context'],
|
|
})
|
|
})
|
|
|
|
it('should expose live SDK permission requests for reconnecting clients', () => {
|
|
const svc = new ConversationService()
|
|
|
|
;(svc as any).sessions.set('session-pending-permission', {
|
|
proc: { pid: 1 },
|
|
outputCallbacks: [],
|
|
workDir: process.cwd(),
|
|
permissionMode: 'default',
|
|
sdkToken: 'token',
|
|
sdkSocket: null,
|
|
pendingOutbound: [],
|
|
stderrLines: [],
|
|
sdkMessages: [],
|
|
initMessage: null,
|
|
pendingPermissionRequests: new Map(),
|
|
})
|
|
|
|
;(svc as any).handleSdkPayload('session-pending-permission', JSON.stringify({
|
|
type: 'control_request',
|
|
request_id: 'request-ask-1',
|
|
request: {
|
|
subtype: 'can_use_tool',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tool-ask-1',
|
|
input: {
|
|
questions: [
|
|
{
|
|
header: 'Scope',
|
|
question: 'Which scope?',
|
|
options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }],
|
|
},
|
|
],
|
|
},
|
|
description: 'Answer questions?',
|
|
},
|
|
}))
|
|
|
|
expect(svc.getPendingPermissionRequests('session-pending-permission')).toEqual([
|
|
{
|
|
requestId: 'request-ask-1',
|
|
toolName: 'AskUserQuestion',
|
|
toolUseId: 'tool-ask-1',
|
|
input: {
|
|
questions: [
|
|
{
|
|
header: 'Scope',
|
|
question: 'Which scope?',
|
|
options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }],
|
|
},
|
|
],
|
|
},
|
|
description: 'Answer questions?',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should reconstruct usage and metadata from a persisted transcript', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
|
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-'))
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
|
|
process.env.ANTHROPIC_API_KEY = 'test-key'
|
|
|
|
try {
|
|
const svc = new SessionService()
|
|
const { sessionId } = await svc.createSession(workDir)
|
|
const found = await svc.findSessionFile(sessionId)
|
|
expect(found).not.toBeNull()
|
|
|
|
await fs.appendFile(found!.filePath, JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-04-27T12:00:00.000Z',
|
|
cwd: workDir,
|
|
version: '999.0.0-test',
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'mock-model',
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
usage: {
|
|
input_tokens: 1234,
|
|
output_tokens: 56,
|
|
cache_read_input_tokens: 7,
|
|
cache_creation_input_tokens: 8,
|
|
server_tool_use: { web_search_requests: 1 },
|
|
},
|
|
},
|
|
}) + '\n')
|
|
|
|
const metadata = await svc.getTranscriptMetadata(sessionId)
|
|
const usage = await svc.getTranscriptUsage(sessionId)
|
|
|
|
expect(metadata).toMatchObject({
|
|
cwd: workDir,
|
|
version: '999.0.0-test',
|
|
model: 'mock-model',
|
|
})
|
|
expect(usage).toMatchObject({
|
|
source: 'transcript',
|
|
totalInputTokens: 1234,
|
|
totalOutputTokens: 56,
|
|
totalCacheReadInputTokens: 7,
|
|
totalCacheCreationInputTokens: 8,
|
|
totalWebSearchRequests: 1,
|
|
})
|
|
expect(usage?.models[0]?.model).toBe('mock-model')
|
|
} finally {
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
if (previousAnthropicApiKey === undefined) {
|
|
delete process.env.ANTHROPIC_API_KEY
|
|
} else {
|
|
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
|
|
}
|
|
await fs.rm(tmpConfigDir, { recursive: true, force: true })
|
|
await fs.rm(workDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('should reconstruct Sonnet 4.6 transcript usage before CLI config is initialized', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const previousNodeEnv = process.env.NODE_ENV
|
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-sonnet-'))
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-sonnet-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
|
|
process.env.NODE_ENV = 'development'
|
|
|
|
try {
|
|
const svc = new SessionService()
|
|
const { sessionId } = await svc.createSession(workDir)
|
|
const found = await svc.findSessionFile(sessionId)
|
|
expect(found).not.toBeNull()
|
|
|
|
await fs.appendFile(found!.filePath, JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-04-27T12:00:00.000Z',
|
|
cwd: workDir,
|
|
version: '999.0.0-test',
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-6',
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 20,
|
|
},
|
|
},
|
|
}) + '\n')
|
|
|
|
const usage = await svc.getTranscriptUsage(sessionId)
|
|
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
|
|
|
|
expect(usage?.models[0]?.model).toBe('claude-sonnet-4-6')
|
|
expect(usage?.models[0]?.contextWindow).toBe(200_000)
|
|
expect(contextEstimate?.model).toBe('claude-sonnet-4-6')
|
|
expect(contextEstimate?.totalTokens).toBe(120)
|
|
expect(contextEstimate?.rawMaxTokens).toBe(200_000)
|
|
expect(contextEstimate?.categories.some((category) => category.name === 'Output tokens' && category.tokens === 20)).toBe(true)
|
|
} finally {
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
if (previousNodeEnv === undefined) {
|
|
delete process.env.NODE_ENV
|
|
} else {
|
|
process.env.NODE_ENV = previousNodeEnv
|
|
}
|
|
await fs.rm(tmpConfigDir, { recursive: true, force: true })
|
|
await fs.rm(workDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('should use active provider model context windows for transcript estimates', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const previousNodeEnv = process.env.NODE_ENV
|
|
const previousModelContextWindows = process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS
|
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-provider-'))
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-provider-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
|
|
process.env.NODE_ENV = 'development'
|
|
delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS
|
|
|
|
try {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'minimax',
|
|
name: 'MiniMax',
|
|
apiKey: 'provider-key',
|
|
authStrategy: 'auth_token',
|
|
baseUrl: 'https://api.minimaxi.com/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'MiniMax-M3',
|
|
haiku: 'MiniMax-M3',
|
|
sonnet: 'MiniMax-M3',
|
|
opus: 'MiniMax-M3',
|
|
},
|
|
modelContextWindows: {
|
|
'MiniMax-M3': 1_000_000,
|
|
},
|
|
})
|
|
await providerService.activateProvider(provider.id)
|
|
|
|
const svc = new SessionService()
|
|
const { sessionId } = await svc.createSession(workDir)
|
|
const found = await svc.findSessionFile(sessionId)
|
|
expect(found).not.toBeNull()
|
|
|
|
await fs.appendFile(found!.filePath, JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-04-27T12:00:00.000Z',
|
|
cwd: workDir,
|
|
version: '999.0.0-test',
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'MiniMax-M3',
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 20,
|
|
},
|
|
},
|
|
}) + '\n')
|
|
|
|
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
|
|
|
|
expect(contextEstimate?.model).toBe('MiniMax-M3')
|
|
expect(contextEstimate?.rawMaxTokens).toBe(1_000_000)
|
|
} finally {
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
if (previousNodeEnv === undefined) {
|
|
delete process.env.NODE_ENV
|
|
} else {
|
|
process.env.NODE_ENV = previousNodeEnv
|
|
}
|
|
if (previousModelContextWindows === undefined) {
|
|
delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS
|
|
} else {
|
|
process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS = previousModelContextWindows
|
|
}
|
|
await fs.rm(tmpConfigDir, { recursive: true, force: true })
|
|
await fs.rm(workDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('should not report transcript context as full for low-trust media usage spikes', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const previousNodeEnv = process.env.NODE_ENV
|
|
const previousUseBedrock = process.env.CLAUDE_CODE_USE_BEDROCK
|
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-media-'))
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-media-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
|
|
process.env.NODE_ENV = 'development'
|
|
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
|
|
|
|
try {
|
|
const svc = new SessionService()
|
|
const { sessionId } = await svc.createSession(workDir)
|
|
const found = await svc.findSessionFile(sessionId)
|
|
expect(found).not.toBeNull()
|
|
|
|
await fs.appendFile(found!.filePath, JSON.stringify({
|
|
type: 'user',
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-04-27T12:00:00.000Z',
|
|
cwd: workDir,
|
|
message: {
|
|
role: 'user',
|
|
content: [{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'a'.repeat(1024),
|
|
},
|
|
}],
|
|
},
|
|
}) + '\n')
|
|
await fs.appendFile(found!.filePath, JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: crypto.randomUUID(),
|
|
timestamp: '2026-04-27T12:00:01.000Z',
|
|
cwd: workDir,
|
|
version: '999.0.0-test',
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-6',
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
usage: {
|
|
input_tokens: 1_000_000,
|
|
output_tokens: 10,
|
|
},
|
|
},
|
|
}) + '\n')
|
|
|
|
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
|
|
|
|
expect(contextEstimate?.rawMaxTokens).toBe(200_000)
|
|
expect(contextEstimate?.totalTokens).toBeLessThan(200_000)
|
|
expect(contextEstimate?.percentage).toBeLessThan(100)
|
|
expect(contextEstimate?.categories[0]?.name).toBe('Estimated context')
|
|
} finally {
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
if (previousNodeEnv === undefined) {
|
|
delete process.env.NODE_ENV
|
|
} else {
|
|
process.env.NODE_ENV = previousNodeEnv
|
|
}
|
|
if (previousUseBedrock === undefined) {
|
|
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
|
} else {
|
|
process.env.CLAUDE_CODE_USE_BEDROCK = previousUseBedrock
|
|
}
|
|
await fs.rm(tmpConfigDir, { recursive: true, force: true })
|
|
await fs.rm(workDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// WebSocket integration tests (with mock CLI using the SDK websocket protocol)
|
|
// ============================================================================
|
|
|
|
describe('WebSocket Chat Integration', () => {
|
|
let server: ReturnType<typeof Bun.serve>
|
|
let baseUrl: string
|
|
let wsUrl: string
|
|
let tmpDir: string
|
|
|
|
function git(cwd: string, ...args: string[]): string {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
})
|
|
}
|
|
|
|
async function createCleanGitRepo(): Promise<string> {
|
|
const workDir = path.join(
|
|
tmpDir,
|
|
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
)
|
|
|
|
await fs.mkdir(workDir, { recursive: true })
|
|
git(workDir, 'init')
|
|
git(workDir, 'config', 'user.email', 'conversations@example.com')
|
|
git(workDir, 'config', 'user.name', 'Conversations Test')
|
|
git(workDir, 'checkout', '-b', 'main')
|
|
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
|
git(workDir, 'add', 'README.md')
|
|
git(workDir, 'commit', '-m', 'initial')
|
|
git(workDir, 'checkout', '-b', 'feature/rail')
|
|
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
|
|
git(workDir, 'add', 'feature.txt')
|
|
git(workDir, 'commit', '-m', 'feature')
|
|
git(workDir, 'checkout', 'main')
|
|
|
|
return workDir
|
|
}
|
|
|
|
async function withMockInitMode<T>(
|
|
mode: string | undefined,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousMode = process.env.MOCK_SDK_INIT_MODE
|
|
|
|
if (mode) {
|
|
process.env.MOCK_SDK_INIT_MODE = mode
|
|
} else {
|
|
delete process.env.MOCK_SDK_INIT_MODE
|
|
}
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousMode === undefined) {
|
|
delete process.env.MOCK_SDK_INIT_MODE
|
|
} else {
|
|
process.env.MOCK_SDK_INIT_MODE = previousMode
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withMockInitDelay<T>(
|
|
delayMs: number | undefined,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousDelay = process.env.MOCK_SDK_INIT_DELAY_MS
|
|
|
|
if (delayMs && delayMs > 0) {
|
|
process.env.MOCK_SDK_INIT_DELAY_MS = String(delayMs)
|
|
} else {
|
|
delete process.env.MOCK_SDK_INIT_DELAY_MS
|
|
}
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousDelay === undefined) {
|
|
delete process.env.MOCK_SDK_INIT_DELAY_MS
|
|
} else {
|
|
process.env.MOCK_SDK_INIT_DELAY_MS = previousDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withMockStreamDelay<T>(
|
|
delayMs: number | undefined,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousDelay = process.env.MOCK_SDK_STREAM_DELAY_MS
|
|
|
|
if (delayMs && delayMs > 0) {
|
|
process.env.MOCK_SDK_STREAM_DELAY_MS = String(delayMs)
|
|
} else {
|
|
delete process.env.MOCK_SDK_STREAM_DELAY_MS
|
|
}
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousDelay === undefined) {
|
|
delete process.env.MOCK_SDK_STREAM_DELAY_MS
|
|
} else {
|
|
process.env.MOCK_SDK_STREAM_DELAY_MS = previousDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withMockMcpStatusDelay<T>(
|
|
delayMs: number | undefined,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousDelay = process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
|
|
|
if (delayMs && delayMs > 0) {
|
|
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = String(delayMs)
|
|
} else {
|
|
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
|
}
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousDelay === undefined) {
|
|
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
|
} else {
|
|
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = previousDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withMockExitAfterFirstUser<T>(
|
|
delayMs: number | undefined,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousDelay = process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
|
|
|
if (delayMs && delayMs > 0) {
|
|
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = String(delayMs)
|
|
} else {
|
|
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
|
}
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousDelay === undefined) {
|
|
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
|
} else {
|
|
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = previousDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withMockStartupStdoutExit<T>(
|
|
stdout: string,
|
|
exitDelayMs: number,
|
|
callback: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousStdout = process.env.MOCK_SDK_STARTUP_STDOUT
|
|
const previousExitDelay = process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
|
|
|
|
process.env.MOCK_SDK_STARTUP_STDOUT = stdout
|
|
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = String(exitDelayMs)
|
|
|
|
try {
|
|
return await callback()
|
|
} finally {
|
|
if (previousStdout === undefined) {
|
|
delete process.env.MOCK_SDK_STARTUP_STDOUT
|
|
} else {
|
|
process.env.MOCK_SDK_STARTUP_STDOUT = previousStdout
|
|
}
|
|
|
|
if (previousExitDelay === undefined) {
|
|
delete process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
|
|
} else {
|
|
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = previousExitDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for completion for session ${sessionId}`))
|
|
}, 30000)
|
|
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({ type: 'user_message', content }))
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
if (allowError) {
|
|
resolve()
|
|
} else {
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(`WebSocket error for session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
return messages
|
|
}
|
|
|
|
async function runTurnUntilComplete(sessionId: string, content: string): Promise<any[]> {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for terminal event for session ${sessionId}`))
|
|
}, 10000)
|
|
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({ type: 'user_message', content }))
|
|
}
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(`WebSocket error for session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
return messages
|
|
}
|
|
|
|
async function waitUntil(
|
|
predicate: () => boolean | Promise<boolean>,
|
|
label: string,
|
|
timeoutMs = 8000,
|
|
): Promise<void> {
|
|
const startedAt = Date.now()
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (await predicate()) return
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
}
|
|
throw new Error(`Timed out waiting for ${label}`)
|
|
}
|
|
const originalCliPath = process.env.CLAUDE_CLI_PATH
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
process.env.CLAUDE_CLI_PATH = fileURLToPath(
|
|
new URL('./fixtures/mock-sdk-cli.ts', import.meta.url)
|
|
)
|
|
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
|
|
|
const port = 15000 + Math.floor(Math.random() * 1000)
|
|
const { startServer } = await import('../index.js')
|
|
server = startServer(port, '127.0.0.1')
|
|
baseUrl = `http://127.0.0.1:${port}`
|
|
wsUrl = `ws://127.0.0.1:${port}`
|
|
})
|
|
|
|
afterAll(async () => {
|
|
server?.stop(true)
|
|
if (tmpDir) {
|
|
await rmWithRetry(tmpDir)
|
|
}
|
|
if (originalCliPath) {
|
|
process.env.CLAUDE_CLI_PATH = originalCliPath
|
|
} else {
|
|
delete process.env.CLAUDE_CLI_PATH
|
|
}
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
})
|
|
|
|
it('should connect and receive connected event', async () => {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/chat-test-1`)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ws.onmessage = (e) => {
|
|
messages.push(JSON.parse(e.data as string))
|
|
if (messages.length >= 1) {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 3000)
|
|
})
|
|
|
|
expect(messages[0].type).toBe('connected')
|
|
expect(messages[0].sessionId).toBe('chat-test-1')
|
|
})
|
|
|
|
it('should handle stop_generation and return idle status', async () => {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/chat-test-2`)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({ type: 'stop_generation' }))
|
|
}
|
|
if (msg.type === 'status' && msg.state === 'idle') {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 3000)
|
|
})
|
|
|
|
expect(messages.some((m) => m.type === 'status' && m.state === 'idle')).toBe(true)
|
|
})
|
|
|
|
it('should send user_message and receive streamed SDK response', async () => {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/chat-test-3`)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(
|
|
JSON.stringify({ type: 'user_message', content: 'Hello from test' })
|
|
)
|
|
}
|
|
// Wait until we receive completion after the streamed response
|
|
if (
|
|
msg.type === 'message_complete' &&
|
|
messages.some((entry) => entry.type === 'thinking')
|
|
) {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 5000)
|
|
})
|
|
|
|
const types = messages.map((m) => m.type)
|
|
expect(types).toContain('connected')
|
|
expect(types).toContain('status')
|
|
// Mock SDK flow produces text streaming, thinking, and completion events.
|
|
expect(types).toContain('content_start')
|
|
expect(types).toContain('content_delta')
|
|
expect(types).toContain('thinking')
|
|
expect(types).toContain('message_complete')
|
|
|
|
// Verify thinking was first status
|
|
const statusMsgs = messages.filter((m) => m.type === 'status')
|
|
expect(statusMsgs[0].state).toBe('thinking')
|
|
})
|
|
|
|
it('emits a worktree startup status before launching a repository session', async () => {
|
|
const repoDir = await createCleanGitRepo()
|
|
const { sessionId } = await sessionService.createSession(repoDir, {
|
|
branch: 'feature/rail',
|
|
worktree: true,
|
|
})
|
|
|
|
const messages = await runTurn(sessionId, 'Hello from repository launch test')
|
|
const statusVerbs = messages
|
|
.filter((msg) => msg.type === 'status')
|
|
.map((msg) => msg.verb)
|
|
|
|
expect(statusVerbs).toContain('Creating worktree')
|
|
})
|
|
|
|
it('does not emit worktree startup status for an already materialized worktree session', async () => {
|
|
const repoDir = await createCleanGitRepo()
|
|
const { sessionId } = await sessionService.createSession(repoDir, {
|
|
branch: 'feature/rail',
|
|
worktree: true,
|
|
})
|
|
|
|
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
|
|
const worktreePath = launchInfo?.repository?.worktreePath
|
|
expect(worktreePath).toBeTruthy()
|
|
await fs.mkdir(worktreePath!, { recursive: true })
|
|
await sessionService.appendSessionMetadata(sessionId, {
|
|
workDir: worktreePath!,
|
|
repository: launchInfo!.repository,
|
|
})
|
|
await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!)
|
|
|
|
const messages = await runTurn(sessionId, 'Continue in the existing worktree')
|
|
const statusVerbs = messages
|
|
.filter((msg) => msg.type === 'status')
|
|
.map((msg) => msg.verb)
|
|
|
|
expect(statusVerbs).toContain('Thinking')
|
|
expect(statusVerbs).not.toContain('Creating worktree')
|
|
})
|
|
|
|
it('keeps the default startup status for current-worktree repository sessions', async () => {
|
|
const repoDir = await createCleanGitRepo()
|
|
const { sessionId } = await sessionService.createSession(repoDir, {
|
|
branch: 'main',
|
|
worktree: false,
|
|
})
|
|
|
|
const messages = await runTurn(sessionId, 'Hello from current worktree launch test')
|
|
const statusVerbs = messages
|
|
.filter((msg) => msg.type === 'status')
|
|
.map((msg) => msg.verb)
|
|
|
|
expect(statusVerbs).toContain('Thinking')
|
|
expect(statusVerbs).not.toContain('Creating worktree')
|
|
})
|
|
|
|
it('emits the derived session title before the first response completes', async () => {
|
|
const sessionId = `title-fast-${crypto.randomUUID()}`
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error('Timed out waiting for derived session title'))
|
|
}, 5000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({
|
|
type: 'user_message',
|
|
content: '开始优化UI',
|
|
}))
|
|
return
|
|
}
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for title session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
const titleIndex = messages.findIndex((msg) => msg.type === 'session_title_updated')
|
|
const completionIndex = messages.findIndex((msg) => msg.type === 'message_complete')
|
|
expect(titleIndex).toBeGreaterThan(-1)
|
|
expect(completionIndex).toBeGreaterThan(-1)
|
|
expect(messages[titleIndex].title).toBe('开始优化UI')
|
|
expect(titleIndex).toBeLessThan(completionIndex)
|
|
})
|
|
|
|
it('uses the /goal objective for the derived session title', async () => {
|
|
const sessionId = `title-goal-${crypto.randomUUID()}`
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error('Timed out waiting for goal title'))
|
|
}, 5000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({
|
|
type: 'user_message',
|
|
content: '/goal ship the desktop goal card',
|
|
}))
|
|
return
|
|
}
|
|
if (msg.type === 'session_title_updated') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for goal title session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
const title = messages.find((msg) => msg.type === 'session_title_updated')?.title
|
|
expect(title).toBe('ship the desktop goal card')
|
|
})
|
|
|
|
it('should start desktop sessions with disabled thinking when configured', async () => {
|
|
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startOptions: Array<{ thinking?: string; model?: string }> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
if (sid === sessionId) {
|
|
startOptions.push({ thinking: options?.thinking, model: options?.model })
|
|
}
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'settings.json'),
|
|
JSON.stringify({ alwaysThinkingEnabled: false }, null, 2),
|
|
'utf-8',
|
|
)
|
|
|
|
const messages = await runTurn(sessionId, 'Hello without thinking')
|
|
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(startOptions).toEqual([{ thinking: 'disabled', model: undefined }])
|
|
} finally {
|
|
conversationService.startSession = originalStartSession as typeof conversationService.startSession
|
|
conversationService.stopSession(sessionId)
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{}\n', 'utf-8')
|
|
}
|
|
})
|
|
|
|
it('should let the global Thinking setting control DeepSeek desktop sessions', async () => {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'deepseek',
|
|
name: 'DeepSeek Thinking Toggle',
|
|
apiKey: 'key-deepseek-toggle',
|
|
baseUrl: 'https://api.deepseek.com/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'deepseek-v4-pro',
|
|
haiku: 'deepseek-v4-flash',
|
|
sonnet: 'deepseek-v4-pro',
|
|
opus: 'deepseek-v4-pro',
|
|
},
|
|
})
|
|
await providerService.activateProvider(provider.id)
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startOptions: Array<{
|
|
sessionId: string
|
|
thinking?: string
|
|
providerId?: string | null
|
|
}> = []
|
|
const sessionIds: string[] = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
if (sessionIds.includes(sid)) {
|
|
startOptions.push({
|
|
sessionId: sid,
|
|
thinking: options?.thinking,
|
|
providerId: options?.providerId,
|
|
})
|
|
}
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
const disabledSessionId = `ds-think-off-${crypto.randomUUID()}`
|
|
sessionIds.push(disabledSessionId)
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'settings.json'),
|
|
JSON.stringify({ alwaysThinkingEnabled: false }, null, 2),
|
|
'utf-8',
|
|
)
|
|
const disabledMessages = await runTurn(disabledSessionId, 'DeepSeek with global thinking off')
|
|
expect(disabledMessages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
|
|
const enabledSessionId = `ds-think-on-${crypto.randomUUID()}`
|
|
sessionIds.push(enabledSessionId)
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'settings.json'),
|
|
JSON.stringify({ alwaysThinkingEnabled: true }, null, 2),
|
|
'utf-8',
|
|
)
|
|
const enabledMessages = await runTurn(enabledSessionId, 'DeepSeek with global thinking on')
|
|
expect(enabledMessages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
|
|
expect(startOptions).toEqual([
|
|
{
|
|
sessionId: disabledSessionId,
|
|
thinking: 'disabled',
|
|
providerId: provider.id,
|
|
},
|
|
{
|
|
sessionId: enabledSessionId,
|
|
thinking: undefined,
|
|
providerId: provider.id,
|
|
},
|
|
])
|
|
} finally {
|
|
conversationService.startSession = originalStartSession as typeof conversationService.startSession
|
|
for (const sessionId of sessionIds) {
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
await providerService.activateOfficial()
|
|
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{}\n', 'utf-8')
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should continue chat when SDK init arrives only after the first user turn', async () => {
|
|
const messages = await withMockInitMode('on_first_user', () =>
|
|
runTurn('chat-test-lazy-init', 'Hello after lazy init'),
|
|
)
|
|
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
|
expect(
|
|
messages.some(
|
|
(m) => m.type === 'system_notification' && m.subtype === 'init',
|
|
),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('should display CLI /cost local command output', async () => {
|
|
const messages = await runTurn(`chat-cost-${crypto.randomUUID()}`, '/cost')
|
|
|
|
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
|
expect(
|
|
messages.some(
|
|
(m) =>
|
|
m.type === 'content_delta' &&
|
|
typeof m.text === 'string' &&
|
|
m.text.includes('Total cost: $0.0000'),
|
|
),
|
|
).toBe(true)
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
})
|
|
|
|
it('should display CLI /context local command output', async () => {
|
|
const messages = await runTurn(`chat-context-${crypto.randomUUID()}`, '/context')
|
|
|
|
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
|
expect(
|
|
messages.some(
|
|
(m) =>
|
|
m.type === 'content_delta' &&
|
|
typeof m.text === 'string' &&
|
|
m.text.includes('## Context Usage'),
|
|
),
|
|
).toBe(true)
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
})
|
|
|
|
it('should expose structured session inspection data from the active CLI', async () => {
|
|
const sessionId = `chat-inspection-${crypto.randomUUID()}`
|
|
await runTurn(sessionId, 'hello before inspection')
|
|
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection`)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as any
|
|
|
|
expect(body.active).toBe(true)
|
|
expect(body.status.model).toBe('mock-opus')
|
|
expect(body.status.slashCommandCount).toBe(1)
|
|
expect(body.usage.costDisplay).toBe('$0.1234')
|
|
expect(body.usage.source).toBe('current_process')
|
|
expect(body.context.model).toBe('mock-opus')
|
|
expect(body.context.estimateOnly).toBe(true)
|
|
expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }])
|
|
|
|
const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
expect(basicRes.status).toBe(200)
|
|
const basicBody = await basicRes.json() as any
|
|
expect(basicBody.usage.source).toBe('current_process')
|
|
expect(basicBody.context).toBeUndefined()
|
|
})
|
|
|
|
it('should expose context-only inspection without waiting on mcp status', async () => {
|
|
await withMockMcpStatusDelay(2_000, async () => {
|
|
const sessionId = `chat-context-only-${crypto.randomUUID()}`
|
|
await runTurn(sessionId, 'hello before context-only inspection')
|
|
|
|
const startedAt = performance.now()
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=1&contextOnly=1`)
|
|
const elapsedMs = performance.now() - startedAt
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as any
|
|
|
|
expect(body.context.model).toBe('mock-opus')
|
|
expect(body.context.estimateOnly).toBe(true)
|
|
expect(body.usage).toBeUndefined()
|
|
expect(elapsedMs).toBeLessThan(1_500)
|
|
})
|
|
})
|
|
|
|
it('should return initial context for a prewarmed empty session on the first inspection request', async () => {
|
|
await withMockInitDelay(500, async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for prewarm connection for ${sessionId}`))
|
|
}, 5_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(`WebSocket error for prewarm context session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
await waitUntil(
|
|
() => conversationService.hasSession(sessionId),
|
|
`prewarmed CLI process for ${sessionId}`,
|
|
)
|
|
|
|
const startedAt = performance.now()
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=1&contextOnly=1`)
|
|
const elapsedMs = performance.now() - startedAt
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json() as any
|
|
|
|
expect(body.context.model).toBe('mock-opus')
|
|
expect(body.context.totalTokens).toBeGreaterThan(0)
|
|
expect(body.context.percentage).toBe(13)
|
|
expect(body.context.categories.some((category: any) => category.name === 'System prompt')).toBe(true)
|
|
expect(body.errors).toEqual({})
|
|
expect(elapsedMs).toBeLessThan(2_000)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
})
|
|
}, 10_000)
|
|
|
|
it('should complete the client turn when the CLI exits after startup', async () => {
|
|
const messages = await withMockExitAfterFirstUser(50, () =>
|
|
runTurnUntilComplete(`chat-late-exit-${crypto.randomUUID()}`, 'trigger late exit'),
|
|
)
|
|
|
|
expect(
|
|
messages.some(
|
|
(m) =>
|
|
m.type === 'error' &&
|
|
m.code === 'CLI_ERROR' &&
|
|
typeof m.message === 'string' &&
|
|
m.message.includes('CLI process exited unexpectedly'),
|
|
),
|
|
).toBe(true)
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(messages.at(-1)?.type).toBe('message_complete')
|
|
}, 15_000)
|
|
|
|
it('should not duplicate SDK API errors with the final error result', async () => {
|
|
const messages = await runTurnUntilComplete(
|
|
`chat-api-error-${crypto.randomUUID()}`,
|
|
'trigger api error',
|
|
)
|
|
|
|
const errors = messages.filter((m) => m.type === 'error')
|
|
expect(errors).toHaveLength(1)
|
|
expect(errors[0]).toMatchObject({
|
|
code: 'invalid_request',
|
|
message: 'Prompt is too long',
|
|
})
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(messages.at(-1)?.type).toBe('message_complete')
|
|
}, 15_000)
|
|
|
|
it('should not add a CLI exit error after a reported SDK API error', async () => {
|
|
const messages = await runTurnUntilComplete(
|
|
`chat-api-error-exit-${crypto.randomUUID()}`,
|
|
'trigger api error then exit',
|
|
)
|
|
|
|
const errors = messages.filter((m) => m.type === 'error')
|
|
expect(errors).toHaveLength(1)
|
|
expect(errors[0]).toMatchObject({
|
|
code: 'invalid_request',
|
|
message: 'Prompt is too long',
|
|
})
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(messages.at(-1)?.type).toBe('message_complete')
|
|
}, 15_000)
|
|
|
|
it('should handle permission_response without error', async () => {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
// Send a permission response (no active session, should not crash)
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'permission_response',
|
|
requestId: 'test-req-1',
|
|
allowed: true,
|
|
})
|
|
)
|
|
// Give a moment then close
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 500)
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 3000)
|
|
})
|
|
|
|
// Should have received connected and no error
|
|
expect(messages[0].type).toBe('connected')
|
|
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
|
})
|
|
|
|
it('should handle ping/pong', async () => {
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/chat-test-5`)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({ type: 'ping' }))
|
|
}
|
|
if (msg.type === 'pong') {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
setTimeout(() => {
|
|
ws.close()
|
|
resolve()
|
|
}, 3000)
|
|
})
|
|
|
|
expect(messages.some((m) => m.type === 'pong')).toBe(true)
|
|
})
|
|
|
|
it('should start a placeholder REST session and continue it on a later reconnect', async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const firstTurn = await runTurn(sessionId, 'reply with first')
|
|
expect(firstTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(firstTurn.some((m) => m.type === 'error')).toBe(false)
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
const secondTurn = await runTurn(sessionId, 'reply with second')
|
|
expect(secondTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
|
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
|
|
})
|
|
|
|
it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => {
|
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-'))
|
|
let sessionId: string | undefined
|
|
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(projectDir, 'package.json'),
|
|
JSON.stringify({ name: 'issue-247-repro', type: 'module' }, null, 2),
|
|
)
|
|
await fs.mkdir(path.join(projectDir, 'src'), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(projectDir, 'src', 'index.ts'),
|
|
'export function greet(name: string) { return `hello ${name}` }\n',
|
|
)
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: projectDir }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
;({ sessionId } = await createRes.json() as { sessionId: string })
|
|
|
|
const prompts = [
|
|
'Inspect this TypeScript project and summarize what you see.',
|
|
'Plan a small change to add a farewell helper.',
|
|
'Implement the helper in src/index.ts.',
|
|
'Review whether the exported functions are easy to test.',
|
|
'Suggest the next regression test for this project.',
|
|
]
|
|
|
|
for (const prompt of prompts) {
|
|
const messages = await runTurn(sessionId, prompt)
|
|
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
|
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
|
}
|
|
} finally {
|
|
if (sessionId) {
|
|
await conversationService.stopSession(sessionId)
|
|
}
|
|
await rmWithRetry(projectDir)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should clear a desktop session without sending /clear to the CLI turn loop', async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const firstTurn = await runTurn(sessionId, 'message before clear')
|
|
expect(firstTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
|
|
|
const clearTurn = await runTurn(sessionId, '/clear')
|
|
expect(
|
|
clearTurn.some(
|
|
(m) => m.type === 'system_notification' && m.subtype === 'session_cleared',
|
|
),
|
|
).toBe(true)
|
|
expect(clearTurn.some((m) => m.type === 'content_delta')).toBe(false)
|
|
|
|
const messagesRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
|
expect(messagesRes.status).toBe(200)
|
|
const body = await messagesRes.json() as { messages: unknown[] }
|
|
expect(body.messages).toEqual([])
|
|
})
|
|
|
|
it('should reject /clear arguments without clearing the desktop session', async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
await runTurn(sessionId, 'message before invalid clear')
|
|
|
|
const clearTurn = await runTurn(sessionId, '/clear please keep this', true)
|
|
expect(
|
|
clearTurn.some(
|
|
(m) => m.type === 'error' && m.code === 'INVALID_SLASH_COMMAND_ARGS',
|
|
),
|
|
).toBe(true)
|
|
expect(
|
|
clearTurn.some(
|
|
(m) => m.type === 'system_notification' && m.subtype === 'session_cleared',
|
|
),
|
|
).toBe(false)
|
|
|
|
const nextTurn = await runTurn(sessionId, 'message after invalid clear')
|
|
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
|
})
|
|
|
|
it('should include desktop service diagnostics when CLI startup fails', async () => {
|
|
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-'))
|
|
const canonicalWorkDir = await fs.realpath(workDir)
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
await fs.rm(workDir, { recursive: true, force: true })
|
|
|
|
const messages = await runTurn(sessionId, 'trigger startup diagnostics', true)
|
|
const error = messages.find((msg) => msg.type === 'error')
|
|
|
|
expect(error).toMatchObject({
|
|
code: 'WORKDIR_INVALID',
|
|
})
|
|
expect(error?.message).toContain('Desktop service diagnostics:')
|
|
expect(error?.message).toContain(`sessionId: ${sessionId}`)
|
|
expect(error?.message).toContain(`workDir: ${canonicalWorkDir}`)
|
|
expect(error?.message).toContain('runtimeOverride: (none)')
|
|
expect(error?.message).toContain('activeProviderId:')
|
|
expect(error?.message).toContain('configuredProviders:')
|
|
})
|
|
|
|
it('should include CLI stdout diagnostics when startup exits before SDK messages', async () => {
|
|
const sessionId = `chat-startup-stdout-${crypto.randomUUID()}`
|
|
|
|
const messages = await withMockStartupStdoutExit(
|
|
'provider rejected request: invalid model id',
|
|
25,
|
|
() => runTurn(sessionId, 'trigger startup stdout diagnostics', true),
|
|
)
|
|
const error = messages.find((msg) => msg.type === 'error')
|
|
|
|
expect(error).toMatchObject({
|
|
code: 'CLI_START_FAILED',
|
|
})
|
|
expect(error?.message).toContain(
|
|
'CLI exited during startup (code 1): provider rejected request: invalid model id',
|
|
)
|
|
expect(error?.message).toContain('Desktop service diagnostics:')
|
|
}, 10_000)
|
|
|
|
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{ sessionId: string }> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const messages: any[] = []
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
let connected = false
|
|
let awaitingCompletion = false
|
|
let preUserMessageCount = 0
|
|
let resolveCompletion: (() => void) | null = null
|
|
let rejectCompletion: ((err: Error) => void) | null = null
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for prewarm connection for session ${sessionId}`))
|
|
}, 5000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
|
|
if (msg.type === 'connected' && !connected) {
|
|
connected = true
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
resolve()
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
const err = new Error(msg.message)
|
|
clearTimeout(timeout)
|
|
rejectCompletion?.(err)
|
|
reject(err)
|
|
return
|
|
}
|
|
|
|
if (awaitingCompletion && msg.type === 'message_complete') {
|
|
resolveCompletion?.()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
const err = new Error(`WebSocket error for prewarm session ${sessionId}`)
|
|
clearTimeout(timeout)
|
|
rejectCompletion?.(err)
|
|
reject(err)
|
|
}
|
|
})
|
|
|
|
await waitUntil(
|
|
() => startCalls.length === 1 && conversationService.hasSession(sessionId),
|
|
`prewarmed CLI process for ${sessionId}`,
|
|
)
|
|
await waitUntil(async () => {
|
|
const commandsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
|
|
if (!commandsRes.ok) return false
|
|
const { commands } = await commandsRes.json() as { commands?: Array<{ name: string }> }
|
|
if (!Array.isArray(commands)) return false
|
|
return commands.some((command) => command.name === 'help')
|
|
}, `prewarmed slash commands for ${sessionId}`)
|
|
|
|
preUserMessageCount = messages.length
|
|
expect(
|
|
messages
|
|
.slice(0, preUserMessageCount)
|
|
.some((msg) => ['content_start', 'content_delta', 'thinking', 'message_complete'].includes(msg.type)),
|
|
).toBe(false)
|
|
|
|
awaitingCompletion = true
|
|
const completion = new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for completion after prewarm for session ${sessionId}`))
|
|
}, 10_000)
|
|
resolveCompletion = () => {
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
}
|
|
rejectCompletion = (err) => {
|
|
clearTimeout(timeout)
|
|
reject(err)
|
|
}
|
|
})
|
|
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after prewarm' }))
|
|
await completion
|
|
|
|
expect(startCalls).toHaveLength(1)
|
|
expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
|
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
expect(messages.some((msg) => msg.type === 'error')).toBe(false)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should pass the active provider id into default desktop sessions', async () => {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'jiekouai',
|
|
name: 'Active Default Provider',
|
|
apiKey: 'key-active-default',
|
|
baseUrl: 'https://api.jiekou.ai/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'active-main',
|
|
haiku: 'active-haiku',
|
|
sonnet: 'active-sonnet',
|
|
opus: 'active-opus',
|
|
},
|
|
})
|
|
await providerService.activateProvider(provider.id)
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
const messages = await runTurn(sessionId, 'first turn with active provider')
|
|
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
expect(startCalls).toHaveLength(1)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: provider.id,
|
|
},
|
|
})
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
await providerService.activateOfficial()
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should isolate provider runtime overrides across parallel sessions', async () => {
|
|
const providerService = new ProviderService()
|
|
const providerA = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Parallel Provider A',
|
|
apiKey: 'key-parallel-a',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'parallel-a-main',
|
|
haiku: 'parallel-a-haiku',
|
|
sonnet: 'parallel-a-sonnet',
|
|
opus: 'parallel-a-opus',
|
|
},
|
|
})
|
|
const providerB = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Parallel Provider B',
|
|
apiKey: 'key-parallel-b',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'parallel-b-main',
|
|
haiku: 'parallel-b-haiku',
|
|
sonnet: 'parallel-b-sonnet',
|
|
opus: 'parallel-b-opus',
|
|
},
|
|
})
|
|
|
|
const createSession = async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
return sessionId
|
|
}
|
|
const [sessionA, sessionB] = await Promise.all([createSession(), createSession()])
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const runRuntimeTurn = (
|
|
sessionId: string,
|
|
providerId: string,
|
|
modelId: string,
|
|
content: string,
|
|
) => new Promise<void>((resolve, reject) => {
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for parallel runtime turn for ${sessionId}`))
|
|
}, 10_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId,
|
|
modelId,
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content }))
|
|
return
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for parallel runtime session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
try {
|
|
await Promise.all([
|
|
runRuntimeTurn(sessionA, providerA.id, 'parallel-a-sonnet', 'turn on provider a'),
|
|
runRuntimeTurn(sessionB, providerB.id, 'parallel-b-opus', 'turn on provider b'),
|
|
])
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls.find((call) => call.sessionId === sessionA)).toMatchObject({
|
|
options: {
|
|
providerId: providerA.id,
|
|
model: 'parallel-a-sonnet',
|
|
},
|
|
})
|
|
expect(startCalls.find((call) => call.sessionId === sessionB)).toMatchObject({
|
|
options: {
|
|
providerId: providerB.id,
|
|
model: 'parallel-b-opus',
|
|
},
|
|
})
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionA)
|
|
conversationService.stopSession(sessionB)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should restart a prewarm that began before runtime config arrived', async () => {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider Late Runtime',
|
|
apiKey: 'key-late-runtime',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'late-main',
|
|
haiku: 'late-haiku',
|
|
sonnet: 'late-sonnet',
|
|
opus: 'late-opus',
|
|
},
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
let releaseFirstStart!: () => void
|
|
const firstStartGate = new Promise<void>((resolve) => {
|
|
releaseFirstStart = resolve
|
|
})
|
|
let markFirstStart!: () => void
|
|
const firstStartEntered = new Promise<void>((resolve) => {
|
|
markFirstStart = resolve
|
|
})
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
if (startCalls.length === 1) {
|
|
markFirstStart()
|
|
await firstStartGate
|
|
}
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for late runtime prewarm connection for session ${sessionId}`))
|
|
}, 5000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for late runtime prewarm session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
await firstStartEntered
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: provider.id,
|
|
modelId: 'late-sonnet',
|
|
}))
|
|
releaseFirstStart()
|
|
|
|
await waitUntil(async () => startCalls.length >= 2, `runtime restart for ${sessionId}`)
|
|
await waitUntil(
|
|
async () => messages.some((msg) => msg.type === 'status' && msg.state === 'idle'),
|
|
`runtime restart idle status for ${sessionId}`,
|
|
)
|
|
|
|
expect(startCalls[0]).toMatchObject({ sessionId })
|
|
expect(startCalls[0]?.options?.providerId).toBeNull()
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: provider.id,
|
|
model: 'late-sonnet',
|
|
},
|
|
})
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should wait for a runtime restart queued during first-turn startup before sending that turn', async () => {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider First Turn Runtime',
|
|
apiKey: 'key-first-turn-runtime',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'first-turn-main',
|
|
haiku: 'first-turn-haiku',
|
|
sonnet: 'first-turn-sonnet',
|
|
opus: 'first-turn-opus',
|
|
},
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const originalSendMessage = conversationService.sendMessage.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
const sendCalls: Array<{ content: string; startCallCount: number }> = []
|
|
let releaseFirstStart!: () => void
|
|
const firstStartGate = new Promise<void>((resolve) => {
|
|
releaseFirstStart = resolve
|
|
})
|
|
let markFirstStart!: () => void
|
|
const firstStartEntered = new Promise<void>((resolve) => {
|
|
markFirstStart = resolve
|
|
})
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
if (startCalls.length === 1) {
|
|
markFirstStart()
|
|
await firstStartGate
|
|
}
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
conversationService.sendMessage = (function patchedSendMessage(
|
|
sid: string,
|
|
content: string,
|
|
attachments?: any,
|
|
) {
|
|
sendCalls.push({ content, startCallCount: startCalls.length })
|
|
return originalSendMessage(sid, content, attachments)
|
|
}) as typeof conversationService.sendMessage
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for first-turn runtime synchronization for session ${sessionId}`))
|
|
}, 15_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
void firstStartEntered.then(() => {
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn while runtime changes' }))
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: provider.id,
|
|
modelId: 'first-turn-sonnet',
|
|
}))
|
|
releaseFirstStart()
|
|
})
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for first-turn runtime synchronization session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[0]).toMatchObject({ sessionId })
|
|
expect(startCalls[0]?.options?.providerId).toBeNull()
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: provider.id,
|
|
model: 'first-turn-sonnet',
|
|
},
|
|
})
|
|
expect(sendCalls).toEqual([{
|
|
content: 'first turn while runtime changes',
|
|
startCallCount: 2,
|
|
}])
|
|
expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.sendMessage = originalSendMessage
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should keep the session idle in the UI while applying a runtime-only model switch', async () => {
|
|
const providerService = new ProviderService()
|
|
const provider = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider Idle Runtime',
|
|
apiKey: 'key-idle-runtime',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'idle-main',
|
|
haiku: 'idle-haiku',
|
|
sonnet: 'idle-sonnet',
|
|
opus: 'idle-opus',
|
|
},
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for idle runtime switch connection for session ${sessionId}`))
|
|
}, 5000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for idle runtime switch session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
await waitUntil(
|
|
() => startCalls.length === 1 && conversationService.hasSession(sessionId),
|
|
`prewarmed CLI process for idle runtime switch ${sessionId}`,
|
|
)
|
|
await waitUntil(async () => {
|
|
const commandsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
|
|
if (!commandsRes.ok) return false
|
|
const { commands } = await commandsRes.json() as { commands?: Array<{ name: string }> }
|
|
return Array.isArray(commands) && commands.some((command) => command.name === 'help')
|
|
}, `prewarmed slash commands for idle runtime switch ${sessionId}`)
|
|
|
|
const switchStartIndex = messages.length
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: provider.id,
|
|
modelId: 'idle-sonnet',
|
|
}))
|
|
|
|
await waitUntil(
|
|
async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'),
|
|
`idle runtime switch completion for ${sessionId}`,
|
|
)
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: provider.id,
|
|
model: 'idle-sonnet',
|
|
},
|
|
})
|
|
expect(
|
|
messages
|
|
.slice(switchStartIndex)
|
|
.filter((msg) => msg.type === 'status')
|
|
.map((msg) => msg.state),
|
|
).toEqual(['idle'])
|
|
expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should keep the session idle in the UI while restarting for a bypass permission switch', async () => {
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for idle permission switch connection for session ${sessionId}`))
|
|
}, 5000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for idle permission switch session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
await waitUntil(
|
|
() => startCalls.length === 1 && conversationService.hasSession(sessionId),
|
|
`prewarmed CLI process for idle permission switch ${sessionId}`,
|
|
)
|
|
await waitUntil(async () => {
|
|
const commandsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
|
|
if (!commandsRes.ok) return false
|
|
const { commands } = await commandsRes.json() as { commands?: Array<{ name: string }> }
|
|
return Array.isArray(commands) && commands.some((command) => command.name === 'help')
|
|
}, `prewarmed slash commands for idle permission switch ${sessionId}`)
|
|
|
|
const switchStartIndex = messages.length
|
|
ws.send(JSON.stringify({
|
|
type: 'set_permission_mode',
|
|
mode: 'bypassPermissions',
|
|
}))
|
|
|
|
await waitUntil(
|
|
async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'),
|
|
`idle permission switch completion for ${sessionId}`,
|
|
)
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'bypassPermissions',
|
|
},
|
|
})
|
|
expect(
|
|
messages
|
|
.slice(switchStartIndex)
|
|
.filter((msg) => msg.type === 'status')
|
|
.map((msg) => msg.state),
|
|
).toEqual(['idle'])
|
|
expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should persist permission changes made before the CLI starts', async () => {
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for inactive permission switch connection for session ${sessionId}`))
|
|
}, 5000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({
|
|
type: 'set_permission_mode',
|
|
mode: 'acceptEdits',
|
|
}))
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for inactive permission switch session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
await waitUntil(async () => {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
if (!res.ok) return false
|
|
const body = await res.json() as { status?: { permissionMode?: string } }
|
|
return body.status?.permissionMode === 'acceptEdits'
|
|
}, `persisted inactive permission switch for ${sessionId}`)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for first turn after inactive permission switch for session ${sessionId}`))
|
|
}, 10_000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after permission switch' }))
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(1)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'acceptEdits',
|
|
},
|
|
})
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should restart when switching from bypass permissions back to default', async () => {
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'bypassPermissions' }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for bypass-to-default permission switch connection for session ${sessionId}`))
|
|
}, 5000)
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
if (msg.type === 'connected') {
|
|
clearTimeout(timeout)
|
|
ws.send(JSON.stringify({ type: 'prewarm_session' }))
|
|
resolve()
|
|
}
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
reject(new Error(msg.message))
|
|
}
|
|
}
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for bypass-to-default permission switch session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
await waitUntil(
|
|
() => startCalls.length === 1 && conversationService.hasSession(sessionId),
|
|
`prewarmed CLI process for bypass-to-default permission switch ${sessionId}`,
|
|
)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'bypassPermissions',
|
|
},
|
|
})
|
|
|
|
const switchStartIndex = messages.length
|
|
ws.send(JSON.stringify({
|
|
type: 'set_permission_mode',
|
|
mode: 'default',
|
|
}))
|
|
|
|
await waitUntil(
|
|
async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'),
|
|
`bypass-to-default permission switch completion for ${sessionId}`,
|
|
)
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'default',
|
|
},
|
|
})
|
|
await waitUntil(async () => {
|
|
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
|
|
if (!res.ok) return false
|
|
const body = await res.json() as { status?: { permissionMode?: string } }
|
|
return body.status?.permissionMode === 'default'
|
|
}, `persisted bypass-to-default permission switch for ${sessionId}`)
|
|
expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => {
|
|
const providerService = new ProviderService()
|
|
const activeProvider = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Current Valid Provider',
|
|
apiKey: 'key-current-valid',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'current-main',
|
|
haiku: 'current-haiku',
|
|
sonnet: 'current-sonnet',
|
|
opus: 'current-opus',
|
|
},
|
|
})
|
|
await providerService.activateProvider(activeProvider.id)
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const staleProviderId = crypto.randomUUID()
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
const messages: any[] = []
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for stale runtime resume for session ${sessionId}`))
|
|
}, 10_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
messages.push(msg)
|
|
|
|
if (msg.type === 'connected') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: staleProviderId,
|
|
modelId: 'stale-model',
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket error for stale runtime resume session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(1)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: activeProvider.id,
|
|
},
|
|
})
|
|
expect(startCalls[0]?.options?.model).not.toBe('stale-model')
|
|
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
} finally {
|
|
ws.close()
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
await providerService.activateOfficial()
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should preserve ChatGPT Official as the active default runtime after restart', async () => {
|
|
const providerService = new ProviderService()
|
|
await providerService.activateProvider('openai-official')
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
const messages = await runTurn(sessionId, 'default ChatGPT Official runtime')
|
|
|
|
expect(startCalls).toHaveLength(1)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: 'openai-official',
|
|
},
|
|
})
|
|
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
await expect(providerService.listProviders()).resolves.toMatchObject({
|
|
activeId: 'openai-official',
|
|
})
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
await providerService.activateOfficial()
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should resume streaming to a reconnected client during an active turn', async () => {
|
|
await withMockStreamDelay(150, async () => {
|
|
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
|
|
const firstMessages: any[] = []
|
|
const secondMessages: any[] = []
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
let reconnected = false
|
|
let ws2: WebSocket | null = null
|
|
const timeout = setTimeout(() => {
|
|
ws2?.close()
|
|
reject(new Error(`Timed out waiting for reconnect completion for session ${sessionId}`))
|
|
}, 10_000)
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeout)
|
|
ws2?.close()
|
|
resolve()
|
|
}
|
|
|
|
const handleFailure = (message: string) => {
|
|
clearTimeout(timeout)
|
|
ws2?.close()
|
|
reject(new Error(message))
|
|
}
|
|
|
|
const ws1 = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
ws1.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
firstMessages.push(msg)
|
|
|
|
if (msg.type === 'connected') {
|
|
ws1.send(JSON.stringify({ type: 'user_message', content: 'resume after reconnect' }))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'thinking' && !reconnected) {
|
|
reconnected = true
|
|
ws1.close()
|
|
|
|
setTimeout(() => {
|
|
ws2 = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
ws2.onmessage = (reconnectEvent) => {
|
|
const reconnectMsg = JSON.parse(reconnectEvent.data as string)
|
|
secondMessages.push(reconnectMsg)
|
|
if (reconnectMsg.type === 'error') {
|
|
handleFailure(reconnectMsg.message)
|
|
return
|
|
}
|
|
if (reconnectMsg.type === 'message_complete') {
|
|
cleanup()
|
|
}
|
|
}
|
|
ws2.onerror = () => handleFailure(`WebSocket reconnect error for session ${sessionId}`)
|
|
}, 50)
|
|
}
|
|
}
|
|
|
|
ws1.onerror = () => handleFailure(`Initial WebSocket error for session ${sessionId}`)
|
|
})
|
|
|
|
expect(firstMessages.some((msg) => msg.type === 'thinking')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'connected')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('should stream one active turn to multiple connected clients', async () => {
|
|
await withMockStreamDelay(150, async () => {
|
|
const sessionId = `chat-multi-client-${crypto.randomUUID()}`
|
|
const firstMessages: any[] = []
|
|
const secondMessages: any[] = []
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
let secondConnected = false
|
|
let firstComplete = false
|
|
let secondComplete = false
|
|
let ws2: WebSocket | null = null
|
|
|
|
const timeout = setTimeout(() => {
|
|
ws1.close()
|
|
ws2?.close()
|
|
reject(new Error(`Timed out waiting for both clients to complete for session ${sessionId}`))
|
|
}, 10_000)
|
|
|
|
const cleanup = () => {
|
|
if (!firstComplete || !secondComplete) return
|
|
clearTimeout(timeout)
|
|
ws1.close()
|
|
ws2?.close()
|
|
resolve()
|
|
}
|
|
|
|
const handleFailure = (message: string) => {
|
|
clearTimeout(timeout)
|
|
ws1.close()
|
|
ws2?.close()
|
|
reject(new Error(message))
|
|
}
|
|
|
|
const ws1 = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
ws1.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
firstMessages.push(msg)
|
|
|
|
if (msg.type === 'connected') {
|
|
ws1.send(JSON.stringify({ type: 'user_message', content: 'multi client stream' }))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'thinking' && !secondConnected) {
|
|
secondConnected = true
|
|
ws2 = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
ws2.onmessage = (secondEvent) => {
|
|
const secondMsg = JSON.parse(secondEvent.data as string)
|
|
secondMessages.push(secondMsg)
|
|
if (secondMsg.type === 'error') {
|
|
handleFailure(secondMsg.message)
|
|
return
|
|
}
|
|
if (secondMsg.type === 'message_complete') {
|
|
secondComplete = true
|
|
cleanup()
|
|
}
|
|
}
|
|
ws2.onerror = () => handleFailure(`Second WebSocket error for session ${sessionId}`)
|
|
}
|
|
|
|
if (msg.type === 'message_complete') {
|
|
firstComplete = true
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
ws1.onerror = () => handleFailure(`First WebSocket error for session ${sessionId}`)
|
|
})
|
|
|
|
expect(firstMessages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
|
expect(firstMessages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'connected')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'content_delta')).toBe(true)
|
|
expect(secondMessages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('should keep using the selected runtime config across the whole session until changed', async () => {
|
|
const providerService = new ProviderService()
|
|
const providerA = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider A',
|
|
apiKey: 'key-a',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'model-a-main',
|
|
haiku: 'model-a-haiku',
|
|
sonnet: 'model-a-sonnet',
|
|
opus: 'model-a-opus',
|
|
},
|
|
})
|
|
const providerB = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider B',
|
|
apiKey: 'key-b',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'model-b-main',
|
|
haiku: 'model-b-haiku',
|
|
sonnet: 'model-b-sonnet',
|
|
opus: 'model-b-opus',
|
|
},
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
let phase: 'boot' | 'turn1' | 'switching' | 'turn2' | 'turn3' | 'done' = 'boot'
|
|
let switchingTriggered = false
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for runtime persistence flow for session ${sessionId}`))
|
|
}, 15_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
|
|
if (msg.type === 'connected' && phase === 'boot') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: providerA.id,
|
|
modelId: 'model-a-sonnet',
|
|
effortLevel: 'medium',
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' }))
|
|
phase = 'turn1'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn1' && !switchingTriggered) {
|
|
switchingTriggered = true
|
|
phase = 'switching'
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: providerB.id,
|
|
modelId: 'model-b-opus',
|
|
effortLevel: 'max',
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (
|
|
msg.type === 'status' &&
|
|
msg.state === 'idle' &&
|
|
phase === 'switching'
|
|
) {
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'second turn' }))
|
|
phase = 'turn2'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn2') {
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'third turn' }))
|
|
phase = 'turn3'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn3') {
|
|
clearTimeout(timeout)
|
|
phase = 'done'
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error(`WebSocket error for runtime persistence session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: providerA.id,
|
|
model: 'model-a-sonnet',
|
|
effort: 'medium',
|
|
},
|
|
})
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: providerB.id,
|
|
model: 'model-b-opus',
|
|
effort: 'max',
|
|
},
|
|
})
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should wait for an in-flight runtime restart before sending the next user turn', async () => {
|
|
const providerService = new ProviderService()
|
|
const providerA = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider Restart A',
|
|
apiKey: 'key-a',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'restart-a-main',
|
|
haiku: 'restart-a-haiku',
|
|
sonnet: 'restart-a-sonnet',
|
|
opus: 'restart-a-opus',
|
|
},
|
|
})
|
|
const providerB = await providerService.addProvider({
|
|
presetId: 'custom',
|
|
name: 'Provider Restart B',
|
|
apiKey: 'key-b',
|
|
baseUrl: 'http://127.0.0.1:1/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'restart-b-main',
|
|
haiku: 'restart-b-haiku',
|
|
sonnet: 'restart-b-sonnet',
|
|
opus: 'restart-b-opus',
|
|
},
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
try {
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
let phase: 'boot' | 'turn1' | 'turn2' | 'turn3' | 'done' = 'boot'
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for runtime restart synchronization flow for session ${sessionId}`))
|
|
}, 15_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
|
|
if (msg.type === 'connected' && phase === 'boot') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: providerA.id,
|
|
modelId: 'restart-a-sonnet',
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' }))
|
|
phase = 'turn1'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn1') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_runtime_config',
|
|
providerId: providerB.id,
|
|
modelId: 'restart-b-opus',
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'second turn immediately after switch' }))
|
|
phase = 'turn2'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn2') {
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'third turn should reuse restarted runtime' }))
|
|
phase = 'turn3'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn3') {
|
|
clearTimeout(timeout)
|
|
phase = 'done'
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error(`WebSocket error for runtime restart synchronization session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: providerA.id,
|
|
model: 'restart-a-sonnet',
|
|
},
|
|
})
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
providerId: providerB.id,
|
|
model: 'restart-b-opus',
|
|
},
|
|
})
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.stopSession(sessionId)
|
|
}
|
|
}, 20_000)
|
|
|
|
it('should wait for an in-flight permission restart before sending the next user turn', async () => {
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
|
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: process.cwd() }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const { sessionId } = await createRes.json() as { sessionId: string }
|
|
|
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
|
const originalSendMessage = conversationService.sendMessage.bind(conversationService)
|
|
const startCalls: Array<{
|
|
sessionId: string
|
|
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
|
}> = []
|
|
const sendCalls: Array<{
|
|
content: string
|
|
startCallCount: number
|
|
permissionMode: string
|
|
}> = []
|
|
|
|
conversationService.startSession = (async function patchedStartSession(
|
|
sid: string,
|
|
workDir: string,
|
|
sdkUrl: string,
|
|
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
|
|
) {
|
|
startCalls.push({ sessionId: sid, options })
|
|
return originalStartSession(sid, workDir, sdkUrl, options)
|
|
}) as typeof conversationService.startSession
|
|
|
|
conversationService.sendMessage = (function patchedSendMessage(
|
|
sid: string,
|
|
content: string,
|
|
attachments?: any,
|
|
) {
|
|
sendCalls.push({
|
|
content,
|
|
startCallCount: startCalls.length,
|
|
permissionMode: conversationService.getSessionPermissionMode(sid),
|
|
})
|
|
return originalSendMessage(sid, content, attachments)
|
|
}) as typeof conversationService.sendMessage
|
|
|
|
try {
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
let phase: 'boot' | 'turn1' | 'turn2' | 'done' = 'boot'
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for permission restart synchronization flow for session ${sessionId}`))
|
|
}, 15_000)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data as string)
|
|
|
|
if (msg.type === 'connected' && phase === 'boot') {
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn before permission switch' }))
|
|
phase = 'turn1'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'error') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
reject(new Error(msg.message))
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn1') {
|
|
ws.send(JSON.stringify({
|
|
type: 'set_permission_mode',
|
|
mode: 'bypassPermissions',
|
|
}))
|
|
ws.send(JSON.stringify({ type: 'user_message', content: 'second turn immediately after permission switch' }))
|
|
phase = 'turn2'
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'message_complete' && phase === 'turn2') {
|
|
clearTimeout(timeout)
|
|
phase = 'done'
|
|
ws.close()
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error(`WebSocket error for permission restart synchronization session ${sessionId}`))
|
|
}
|
|
})
|
|
|
|
expect(startCalls).toHaveLength(2)
|
|
expect(startCalls[0]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'default',
|
|
},
|
|
})
|
|
expect(startCalls[1]).toMatchObject({
|
|
sessionId,
|
|
options: {
|
|
permissionMode: 'bypassPermissions',
|
|
},
|
|
})
|
|
expect(sendCalls).toMatchObject([
|
|
{
|
|
content: 'first turn before permission switch',
|
|
startCallCount: 1,
|
|
permissionMode: 'default',
|
|
},
|
|
{
|
|
content: 'second turn immediately after permission switch',
|
|
startCallCount: 2,
|
|
permissionMode: 'bypassPermissions',
|
|
},
|
|
])
|
|
} finally {
|
|
conversationService.startSession = originalStartSession
|
|
conversationService.sendMessage = originalSendMessage
|
|
conversationService.stopSession(sessionId)
|
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: 'default' }),
|
|
})
|
|
}
|
|
}, 20_000)
|
|
})
|