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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions server/__tests__/codex-session-events.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';

import { buildCodexSessionCreatedEvent } from '../utils/codexSessionEvents.js';

describe('codex session event payloads', () => {
it('includes projectName when provided', () => {
const projectName = 'C--Users-test-user-dr-claw-project';
const event = buildCodexSessionCreatedEvent({
sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade',
sessionMode: 'research',
projectName,
});

expect(event).toEqual({
type: 'session-created',
sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade',
provider: 'codex',
mode: 'research',
projectName,
});
});

it('keeps backward-compatible payload shape when projectName is missing', () => {
const event = buildCodexSessionCreatedEvent({
sessionId: 'session-no-project',
sessionMode: 'workspace_qa',
});

expect(event).toEqual({
type: 'session-created',
sessionId: 'session-no-project',
provider: 'codex',
mode: 'workspace_qa',
});
});
});
229 changes: 229 additions & 0 deletions server/__tests__/session-lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest';

import {
inferProviderFromMessageType,
resolveProjectName,
enrichSessionEventPayload,
buildLifecycleMessageFromPayload,
} from '../utils/sessionLifecycle.js';

describe('inferProviderFromMessageType', () => {
it.each([
['claude-complete', 'claude'],
['cursor-result', 'cursor'],
['codex-complete', 'codex'],
['gemini-complete', 'gemini'],
['openrouter-complete', 'openrouter'],
['localgpu-complete', 'local'],
['nano-complete', 'nano'],
])('infers %s → %s', (type, expected) => {
expect(inferProviderFromMessageType(type)).toBe(expected);
});

it('returns fallbackProvider when prefix is unknown', () => {
expect(inferProviderFromMessageType('unknown-type', 'codex')).toBe('codex');
});

it('returns null when no prefix matches and no fallback', () => {
expect(inferProviderFromMessageType('unknown-type')).toBeNull();
});

it('handles null/undefined type gracefully', () => {
expect(inferProviderFromMessageType(null)).toBeNull();
expect(inferProviderFromMessageType(undefined)).toBeNull();
});
});

describe('resolveProjectName', () => {
it('returns explicit projectName when provided', () => {
expect(resolveProjectName('my-project', null)).toBe('my-project');
});

it('returns null for empty projectName and no path', () => {
expect(resolveProjectName(null, null)).toBeNull();
expect(resolveProjectName('', '')).toBeNull();
expect(resolveProjectName(' ', null)).toBeNull();
});

it('resolves from projectPath via deps when projectName is missing', () => {
const deps = {
isKnownPath: () => true,
encodePath: (p) => `encoded-${p}`,
};
expect(resolveProjectName(null, '/some/path', deps)).toBe('encoded-/some/path');
});

it('returns null when isKnownPath returns false', () => {
const deps = {
isKnownPath: () => false,
encodePath: () => 'should-not-be-called',
};
expect(resolveProjectName(null, '/unknown/path', deps)).toBeNull();
});

it('returns null when encodePath throws', () => {
const deps = {
isKnownPath: () => true,
encodePath: () => { throw new Error('encode failed'); },
};
expect(resolveProjectName(null, '/bad/path', deps)).toBeNull();
});

it('returns null when deps are not provided and projectName is missing', () => {
expect(resolveProjectName(null, '/some/path')).toBeNull();
});
});

describe('enrichSessionEventPayload', () => {
const deps = {
isKnownPath: () => true,
encodePath: (p) => `encoded-${p}`,
};

it('returns non-object payloads unchanged', () => {
expect(enrichSessionEventPayload(null)).toBeNull();
expect(enrichSessionEventPayload(undefined)).toBeUndefined();
expect(enrichSessionEventPayload('string')).toBe('string');
});

it('ignores non-session message types', () => {
const payload = { type: 'claude-complete', projectPath: '/p' };
expect(enrichSessionEventPayload(payload, null, deps)).toBe(payload);
});

it('enriches session payload with resolved projectName from projectPath', () => {
const payload = { type: 'session-created', projectPath: '/my/project' };
const result = enrichSessionEventPayload(payload, null, deps);
expect(result.projectName).toBe('encoded-/my/project');
expect(result.type).toBe('session-created');
});

it('uses fallbackProjectPath when payload has no projectPath', () => {
const payload = { type: 'session-created' };
const result = enrichSessionEventPayload(payload, '/fallback/path', deps);
expect(result.projectName).toBe('encoded-/fallback/path');
});

it('does not overwrite existing projectName', () => {
const payload = { type: 'session-created', projectName: 'already-set' };
const result = enrichSessionEventPayload(payload, null, deps);
expect(result).toBe(payload);
});

it('returns original payload when resolved name matches existing', () => {
const depsMatch = {
isKnownPath: () => true,
encodePath: () => 'same-name',
};
const payload = { type: 'session-created', projectName: 'same-name' };
expect(enrichSessionEventPayload(payload, null, depsMatch)).toBe(payload);
});
});

describe('buildLifecycleMessageFromPayload', () => {
it('returns null for non-object payloads', () => {
expect(buildLifecycleMessageFromPayload(null)).toBeNull();
expect(buildLifecycleMessageFromPayload(undefined)).toBeNull();
expect(buildLifecycleMessageFromPayload(42)).toBeNull();
});

it('returns null for non-terminal message types', () => {
expect(buildLifecycleMessageFromPayload({ type: 'claude-chunk' })).toBeNull();
expect(buildLifecycleMessageFromPayload({ type: 'session-created' })).toBeNull();
});

it('builds completed lifecycle for -complete suffix', () => {
const now = Date.now();
const result = buildLifecycleMessageFromPayload({
type: 'claude-complete',
sessionId: 'sess-1',
});
expect(result).toMatchObject({
type: 'session-state-changed',
provider: 'claude',
sessionId: 'sess-1',
state: 'completed',
reason: 'claude-complete',
});
expect(result.changedAt).toBeGreaterThanOrEqual(now);
});

it('builds completed lifecycle for cursor-result', () => {
const result = buildLifecycleMessageFromPayload({
type: 'cursor-result',
sessionId: 'cursor-sess',
});
expect(result.state).toBe('completed');
expect(result.provider).toBe('cursor');
});

it('builds failed lifecycle for -error suffix', () => {
const result = buildLifecycleMessageFromPayload({
type: 'codex-error',
sessionId: 'codex-sess',
});
expect(result).toMatchObject({
state: 'failed',
provider: 'codex',
reason: 'codex-error',
});
});

it('prefers actualSessionId over sessionId', () => {
const result = buildLifecycleMessageFromPayload({
type: 'gemini-complete',
sessionId: 'old-id',
actualSessionId: 'real-id',
});
expect(result.sessionId).toBe('real-id');
});

it('uses fallbackProvider when type prefix is unknown', () => {
const result = buildLifecycleMessageFromPayload(
{ type: 'custom-complete', sessionId: 's1' },
'openrouter',
);
expect(result.provider).toBe('openrouter');
});

it('uses payload.provider over fallbackProvider', () => {
const result = buildLifecycleMessageFromPayload(
{ type: 'custom-complete', sessionId: 's1', provider: 'nano' },
'openrouter',
);
expect(result.provider).toBe('nano');
});

it('includes projectName from fallbackProjectName', () => {
const result = buildLifecycleMessageFromPayload(
{ type: 'claude-complete', sessionId: 's1' },
null,
'my-project',
);
expect(result.projectName).toBe('my-project');
});

it('omits projectName when not resolvable', () => {
const result = buildLifecycleMessageFromPayload(
{ type: 'claude-complete', sessionId: 's1' },
null,
null,
);
expect(result).not.toHaveProperty('projectName');
});

it('resolves projectName from projectPath via deps', () => {
const deps = {
isKnownPath: () => true,
encodePath: (p) => `encoded-${p}`,
};
const result = buildLifecycleMessageFromPayload(
{ type: 'claude-error', sessionId: 's1', projectPath: '/proj' },
null,
null,
deps,
);
expect(result.projectName).toBe('encoded-/proj');
expect(result.state).toBe('failed');
});
});
24 changes: 19 additions & 5 deletions server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,14 @@ function mapCliOptionsToSDK(options = {}) {
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
* @param {string} tempDir - Temp directory for cleanup
*/
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
activeSessions.set(sessionId, {
instance: queryInstance,
startTime: Date.now(),
status: 'active',
tempImagePaths,
tempDir
tempDir,
writer,
});
}

Expand Down Expand Up @@ -645,7 +646,7 @@ async function queryClaudeSDK(command, options = {}, ws) {

// Track the query instance for abort capability
if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
}

// Process streaming messages
Expand All @@ -657,7 +658,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
if (message.session_id && !capturedSessionId) {

capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);

// Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
Expand All @@ -681,7 +682,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
type: 'session-created',
sessionId: capturedSessionId,
provider: 'claude',
mode: sessionMode || 'research'
mode: sessionMode || 'research',
projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined,
});
} else {
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
Expand Down Expand Up @@ -779,6 +781,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionId: capturedSessionId,
provider: 'claude',
mode: sessionMode || 'research',
projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined,
});
}

Expand Down Expand Up @@ -938,12 +941,23 @@ async function runClaudeBtw({ question, transcript, cwd, model, signal }) {
}

// Export public API
function rebindClaudeSDKSessionWriter(sessionId, newWriter) {
const session = getSession(sessionId);
if (!session || !session.writer) return false;
if (typeof session.writer.replaceSocket === 'function') {
session.writer.replaceSocket(newWriter.ws || newWriter);
return true;
}
return false;
}

export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getClaudeSDKSessionStartTime,
getActiveClaudeSDKSessions,
rebindClaudeSDKSessionWriter,
resolveToolApproval,
getContextWindowForModel,
runClaudeBtw,
Expand Down
6 changes: 5 additions & 1 deletion server/cursor-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { resolveCursorCliCommand } from './utils/cursorCommand.js';
import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
import { encodeProjectPath } from './projects.js';

// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
Expand Down Expand Up @@ -54,6 +55,7 @@ async function spawnCursor(command, options = {}, ws) {

// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
const encodedProjectName = workingDir ? encodeProjectPath(workingDir) : undefined;
const cursorCommand = resolveCursorCliCommand();

// Synchronous (better-sqlite3) — no await needed.
Expand Down Expand Up @@ -156,6 +158,8 @@ async function spawnCursor(command, options = {}, ws) {
ws.send({
type: 'session-created',
sessionId: capturedSessionId,
provider: 'cursor',
projectName: encodedProjectName,
model: response.model,
cwd: response.cwd,
mode: sessionMode || 'research',
Expand Down Expand Up @@ -271,7 +275,7 @@ async function spawnCursor(command, options = {}, ws) {
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
ws.send({
type: 'claude-complete',
type: 'cursor-complete',
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
Expand Down
Loading