refactor: extract modules from main/index.ts

1 - Pure function extractions:
- constants.ts: Add regex patterns and debugLog for group chat
- group-chat/session-parser.ts: parseParticipantSessionId
- group-chat/output-parser.ts: Text extraction from agent JSONL output
- group-chat/output-buffer.ts: Streaming output buffer management

2 - Dependency injection factories:
- utils/safe-send.ts: createSafeSend for safe IPC messaging
- web-server/web-server-factory.ts: createWebServerFactory for web server

Added 133 new unit tests across 6 test files for full coverage of
extracted modules.
This commit is contained in:
Raza Rauf
2026-01-23 01:52:54 +05:00
parent 57940c28f2
commit 37356c6822
13 changed files with 2351 additions and 587 deletions

View File

@@ -0,0 +1,231 @@
/**
* @file constants.test.ts
* @description Unit tests for main process constants including regex patterns and debug utilities.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
REGEX_MODERATOR_SESSION,
REGEX_MODERATOR_SESSION_TIMESTAMP,
REGEX_PARTICIPANT_UUID,
REGEX_PARTICIPANT_TIMESTAMP,
REGEX_PARTICIPANT_FALLBACK,
REGEX_AI_SUFFIX,
REGEX_AI_TAB_ID,
DEBUG_GROUP_CHAT,
debugLog,
} from '../../main/constants';
describe('main/constants', () => {
describe('REGEX_MODERATOR_SESSION', () => {
it('should match moderator session IDs', () => {
const match = 'group-chat-abc123-moderator-1702934567890'.match(REGEX_MODERATOR_SESSION);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
});
it('should match moderator synthesis session IDs', () => {
const match = 'group-chat-abc123-moderator-synthesis-1702934567890'.match(
REGEX_MODERATOR_SESSION
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
});
it('should not match participant session IDs', () => {
const match = 'group-chat-abc123-participant-Claude-1702934567890'.match(
REGEX_MODERATOR_SESSION
);
expect(match).toBeNull();
});
it('should not match regular session IDs', () => {
const match = 'session-abc123'.match(REGEX_MODERATOR_SESSION);
expect(match).toBeNull();
});
});
describe('REGEX_MODERATOR_SESSION_TIMESTAMP', () => {
it('should match moderator session IDs with timestamp suffix', () => {
const match = 'group-chat-abc123-moderator-1702934567890'.match(
REGEX_MODERATOR_SESSION_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
});
it('should not match moderator synthesis session IDs', () => {
// This pattern expects only digits after "moderator-"
const match = 'group-chat-abc123-moderator-synthesis-1702934567890'.match(
REGEX_MODERATOR_SESSION_TIMESTAMP
);
expect(match).toBeNull();
});
it('should not match session IDs without timestamp', () => {
const match = 'group-chat-abc123-moderator-'.match(REGEX_MODERATOR_SESSION_TIMESTAMP);
expect(match).toBeNull();
});
});
describe('REGEX_PARTICIPANT_UUID', () => {
it('should match participant session IDs with UUID suffix', () => {
const match =
'group-chat-abc123-participant-Claude-550e8400-e29b-41d4-a716-446655440000'.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('Claude');
expect(match![3]).toBe('550e8400-e29b-41d4-a716-446655440000');
});
it('should match participant with hyphenated name and UUID', () => {
const match =
'group-chat-abc123-participant-OpenCode-Ollama-550e8400-e29b-41d4-a716-446655440000'.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('OpenCode-Ollama');
});
it('should be case-insensitive for UUID', () => {
const match =
'group-chat-abc123-participant-Claude-550E8400-E29B-41D4-A716-446655440000'.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
});
it('should not match timestamp suffix as UUID', () => {
const match = 'group-chat-abc123-participant-Claude-1702934567890'.match(
REGEX_PARTICIPANT_UUID
);
expect(match).toBeNull();
});
});
describe('REGEX_PARTICIPANT_TIMESTAMP', () => {
it('should match participant session IDs with timestamp suffix', () => {
const match = 'group-chat-abc123-participant-Claude-1702934567890'.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('Claude');
expect(match![3]).toBe('1702934567890');
});
it('should match participant with hyphenated name and timestamp', () => {
const match = 'group-chat-abc123-participant-OpenCode-Ollama-1702934567890'.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('OpenCode-Ollama');
});
it('should require at least 13 digits for timestamp', () => {
const shortTimestamp = 'group-chat-abc123-participant-Claude-170293456'.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(shortTimestamp).toBeNull();
const longTimestamp = 'group-chat-abc123-participant-Claude-17029345678901'.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(longTimestamp).not.toBeNull();
});
});
describe('REGEX_PARTICIPANT_FALLBACK', () => {
it('should match basic participant session IDs', () => {
const match = 'group-chat-abc123-participant-Claude-anything'.match(
REGEX_PARTICIPANT_FALLBACK
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('Claude');
});
it('should only capture first segment for hyphenated names', () => {
// Fallback is for backwards compatibility with non-hyphenated names
const match = 'group-chat-abc123-participant-OpenCode-Ollama-1702934567890'.match(
REGEX_PARTICIPANT_FALLBACK
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![2]).toBe('OpenCode'); // Only captures up to first hyphen
});
});
describe('REGEX_AI_SUFFIX', () => {
it('should match session IDs with -ai- suffix', () => {
expect('session-123-ai-tab1'.match(REGEX_AI_SUFFIX)).not.toBeNull();
});
it('should not match session IDs without -ai- suffix', () => {
expect('session-123-terminal'.match(REGEX_AI_SUFFIX)).toBeNull();
});
it('should not match session IDs without -ai- suffix pattern', () => {
// Missing the tab ID after -ai-
expect('session-ai-'.match(REGEX_AI_SUFFIX)).toBeNull();
// No -ai- suffix at all
expect('session-123'.match(REGEX_AI_SUFFIX)).toBeNull();
});
it('should match session IDs with -ai-{tabId} even in middle (suffix matches end)', () => {
// This DOES match because the regex looks for -ai-{non-hyphen chars} at the END
expect('session-ai-123'.match(REGEX_AI_SUFFIX)).not.toBeNull();
});
});
describe('REGEX_AI_TAB_ID', () => {
it('should extract tab ID from session ID', () => {
const match = 'session-123-ai-tab1'.match(REGEX_AI_TAB_ID);
expect(match).not.toBeNull();
expect(match![1]).toBe('tab1');
});
it('should extract complex tab IDs', () => {
const match = 'session-123-ai-abc123def'.match(REGEX_AI_TAB_ID);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123def');
});
});
describe('DEBUG_GROUP_CHAT', () => {
it('should be a boolean', () => {
expect(typeof DEBUG_GROUP_CHAT).toBe('boolean');
});
});
describe('debugLog', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('should be a function', () => {
expect(typeof debugLog).toBe('function');
});
it('should accept prefix, message, and additional args', () => {
// Function should not throw regardless of DEBUG_GROUP_CHAT value
expect(() => debugLog('TestPrefix', 'Test message', { extra: 'data' })).not.toThrow();
});
it('should format message with prefix when called', () => {
debugLog('TestPrefix', 'Test message');
// If DEBUG_GROUP_CHAT is true, it will log; if false, it won't
// We're just testing it doesn't throw
});
});
});

View File

@@ -0,0 +1,250 @@
/**
* @file output-buffer.test.ts
* @description Unit tests for group chat output buffer management.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
appendToGroupChatBuffer,
getGroupChatBufferedOutput,
clearGroupChatBuffer,
hasGroupChatBuffer,
} from '../../../main/group-chat/output-buffer';
describe('group-chat/output-buffer', () => {
// Use unique session IDs for each test to avoid state leakage
const getUniqueSessionId = () =>
`test-session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
describe('appendToGroupChatBuffer', () => {
it('should create a new buffer for a new session', () => {
const sessionId = getUniqueSessionId();
const result = appendToGroupChatBuffer(sessionId, 'Hello');
expect(result).toBe(5); // Length of 'Hello'
});
it('should append to existing buffer', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Hello');
const result = appendToGroupChatBuffer(sessionId, ' World');
expect(result).toBe(11); // Length of 'Hello World'
});
it('should return cumulative length after multiple appends', () => {
const sessionId = getUniqueSessionId();
expect(appendToGroupChatBuffer(sessionId, 'A')).toBe(1);
expect(appendToGroupChatBuffer(sessionId, 'BB')).toBe(3);
expect(appendToGroupChatBuffer(sessionId, 'CCC')).toBe(6);
});
it('should handle empty strings', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Start');
const result = appendToGroupChatBuffer(sessionId, '');
expect(result).toBe(5); // Length unchanged
});
it('should handle different sessions independently', () => {
const sessionId1 = getUniqueSessionId();
const sessionId2 = getUniqueSessionId();
appendToGroupChatBuffer(sessionId1, 'Session 1 data');
appendToGroupChatBuffer(sessionId2, 'Session 2');
expect(getGroupChatBufferedOutput(sessionId1)).toBe('Session 1 data');
expect(getGroupChatBufferedOutput(sessionId2)).toBe('Session 2');
});
it('should handle large data efficiently', () => {
const sessionId = getUniqueSessionId();
const largeChunk = 'x'.repeat(10000);
// Append multiple large chunks
for (let i = 0; i < 10; i++) {
appendToGroupChatBuffer(sessionId, largeChunk);
}
const result = appendToGroupChatBuffer(sessionId, 'final');
expect(result).toBe(100005); // 10 * 10000 + 5
// Clean up
clearGroupChatBuffer(sessionId);
});
});
describe('getGroupChatBufferedOutput', () => {
it('should return undefined for non-existent session', () => {
const sessionId = getUniqueSessionId();
expect(getGroupChatBufferedOutput(sessionId)).toBeUndefined();
});
it('should return concatenated output', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Hello');
appendToGroupChatBuffer(sessionId, ' ');
appendToGroupChatBuffer(sessionId, 'World');
expect(getGroupChatBufferedOutput(sessionId)).toBe('Hello World');
// Clean up
clearGroupChatBuffer(sessionId);
});
it('should return empty string for session with empty buffer', () => {
const sessionId = getUniqueSessionId();
// Create buffer with empty string
appendToGroupChatBuffer(sessionId, '');
// Should return undefined because chunks array is empty (no non-empty data)
// Actually, empty string IS pushed to chunks, so it won't be undefined
// But the check is chunks.length === 0, so empty string still counts
const result = getGroupChatBufferedOutput(sessionId);
expect(result).toBe('');
// Clean up
clearGroupChatBuffer(sessionId);
});
it('should preserve newlines and special characters', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Line 1\n');
appendToGroupChatBuffer(sessionId, 'Line 2\t');
appendToGroupChatBuffer(sessionId, 'Special: "quotes" & <brackets>');
const result = getGroupChatBufferedOutput(sessionId);
expect(result).toBe('Line 1\nLine 2\tSpecial: "quotes" & <brackets>');
// Clean up
clearGroupChatBuffer(sessionId);
});
it('should handle unicode characters', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Hello ');
appendToGroupChatBuffer(sessionId, 'World! ');
const result = getGroupChatBufferedOutput(sessionId);
expect(result).toContain('Hello');
expect(result).toContain('World');
// Clean up
clearGroupChatBuffer(sessionId);
});
});
describe('clearGroupChatBuffer', () => {
it('should clear existing buffer', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Some data');
clearGroupChatBuffer(sessionId);
expect(getGroupChatBufferedOutput(sessionId)).toBeUndefined();
});
it('should not throw for non-existent session', () => {
const sessionId = getUniqueSessionId();
expect(() => clearGroupChatBuffer(sessionId)).not.toThrow();
});
it('should allow reuse of session ID after clear', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'First');
clearGroupChatBuffer(sessionId);
appendToGroupChatBuffer(sessionId, 'Second');
expect(getGroupChatBufferedOutput(sessionId)).toBe('Second');
// Clean up
clearGroupChatBuffer(sessionId);
});
});
describe('hasGroupChatBuffer', () => {
it('should return false for non-existent session', () => {
const sessionId = getUniqueSessionId();
expect(hasGroupChatBuffer(sessionId)).toBe(false);
});
it('should return true for session with data', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Some data');
expect(hasGroupChatBuffer(sessionId)).toBe(true);
// Clean up
clearGroupChatBuffer(sessionId);
});
it('should return false after buffer is cleared', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, 'Some data');
clearGroupChatBuffer(sessionId);
expect(hasGroupChatBuffer(sessionId)).toBe(false);
});
it('should return true for buffer with only empty string', () => {
const sessionId = getUniqueSessionId();
appendToGroupChatBuffer(sessionId, '');
// Empty string is still pushed to chunks, so hasBuffer should be true
expect(hasGroupChatBuffer(sessionId)).toBe(true);
// Clean up
clearGroupChatBuffer(sessionId);
});
});
describe('integration scenarios', () => {
it('should handle typical group chat workflow', () => {
const moderatorSession = getUniqueSessionId();
const participantSession = getUniqueSessionId();
// Moderator receives streaming output
appendToGroupChatBuffer(moderatorSession, '{"type": "text", "text": "Hello"}');
appendToGroupChatBuffer(moderatorSession, '\n');
appendToGroupChatBuffer(moderatorSession, '{"type": "text", "text": " World"}');
// Participant receives streaming output
appendToGroupChatBuffer(participantSession, '{"type": "response", "data": "Reply"}');
// Verify independent buffers
expect(hasGroupChatBuffer(moderatorSession)).toBe(true);
expect(hasGroupChatBuffer(participantSession)).toBe(true);
// Get and clear moderator buffer (simulating process exit)
const moderatorOutput = getGroupChatBufferedOutput(moderatorSession);
expect(moderatorOutput).toContain('Hello');
clearGroupChatBuffer(moderatorSession);
// Get and clear participant buffer
const participantOutput = getGroupChatBufferedOutput(participantSession);
expect(participantOutput).toContain('Reply');
clearGroupChatBuffer(participantSession);
// Verify cleanup
expect(hasGroupChatBuffer(moderatorSession)).toBe(false);
expect(hasGroupChatBuffer(participantSession)).toBe(false);
});
it('should handle concurrent appends to different sessions', () => {
const sessions = Array.from({ length: 5 }, () => getUniqueSessionId());
// Simulate concurrent appends
sessions.forEach((sessionId, index) => {
appendToGroupChatBuffer(sessionId, `Session ${index} Part 1`);
});
sessions.forEach((sessionId, index) => {
appendToGroupChatBuffer(sessionId, ` Session ${index} Part 2`);
});
// Verify each session has correct data
sessions.forEach((sessionId, index) => {
const output = getGroupChatBufferedOutput(sessionId);
expect(output).toBe(`Session ${index} Part 1 Session ${index} Part 2`);
clearGroupChatBuffer(sessionId);
});
});
});
});

View File

@@ -0,0 +1,254 @@
/**
* @file output-parser.test.ts
* @description Unit tests for group chat output parsing utilities.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the parsers module before importing the output-parser
vi.mock('../../../main/parsers', () => ({
getOutputParser: vi.fn(),
}));
// Mock the logger to avoid console noise
vi.mock('../../../main/utils/logger', () => ({
logger: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));
import {
extractTextGeneric,
extractTextFromAgentOutput,
extractTextFromStreamJson,
} from '../../../main/group-chat/output-parser';
import { getOutputParser } from '../../../main/parsers';
describe('group-chat/output-parser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('extractTextGeneric', () => {
it('should return raw output if not JSONL format', () => {
const plainText = 'This is plain text output';
expect(extractTextGeneric(plainText)).toBe(plainText);
});
it('should extract result field from JSON', () => {
const jsonOutput = '{"result": "This is the result"}';
expect(extractTextGeneric(jsonOutput)).toBe('This is the result');
});
it('should extract text field from JSON', () => {
const jsonOutput = '{"text": "Some text content"}';
expect(extractTextGeneric(jsonOutput)).toBe('Some text content');
});
it('should extract part.text field from JSON', () => {
const jsonOutput = '{"part": {"text": "Nested text content"}}';
expect(extractTextGeneric(jsonOutput)).toBe('Nested text content');
});
it('should extract message.content field from JSON', () => {
const jsonOutput = '{"message": {"content": "Message content"}}';
expect(extractTextGeneric(jsonOutput)).toBe('Message content');
});
it('should handle multiple JSONL lines', () => {
const jsonlOutput = ['{"text": "Line 1"}', '{"text": "Line 2"}', '{"text": "Line 3"}'].join(
'\n'
);
expect(extractTextGeneric(jsonlOutput)).toBe('Line 1\nLine 2\nLine 3');
});
it('should prefer result over text parts', () => {
const jsonlOutput = [
'{"text": "Streaming part 1"}',
'{"text": "Streaming part 2"}',
'{"result": "Final result"}',
].join('\n');
// result should be returned immediately when found
expect(extractTextGeneric(jsonlOutput)).toBe('Final result');
});
it('should handle empty lines in JSONL', () => {
const jsonlOutput = ['{"text": "Line 1"}', '', '{"text": "Line 2"}'].join('\n');
expect(extractTextGeneric(jsonlOutput)).toBe('Line 1\nLine 2');
});
it('should skip lines with session_id when in JSON context', () => {
// Note: The first non-empty line must start with '{' for JSONL processing
// If first line doesn't start with '{', raw output is returned as-is
const jsonlOutput = [
'{"text": "Actual content"}',
'session_id: abc123', // This line would be skipped in the catch block
].join('\n');
expect(extractTextGeneric(jsonlOutput)).toBe('Actual content');
});
it('should return raw output if first line is not JSON', () => {
const rawOutput = ['session_id: abc123', '{"text": "Actual content"}'].join('\n');
// When first non-empty line doesn't start with '{', returns raw output
expect(extractTextGeneric(rawOutput)).toBe(rawOutput);
});
it('should handle invalid JSON gracefully', () => {
const mixedOutput = [
'{"text": "Valid JSON"}',
'This is not JSON',
'{"text": "More valid JSON"}',
].join('\n');
// Invalid JSON lines that don't start with '{' are included as content
// Lines starting with '{' that fail to parse are skipped
const result = extractTextGeneric(mixedOutput);
expect(result).toContain('Valid JSON');
expect(result).toContain('More valid JSON');
});
it('should handle non-string message.content', () => {
const jsonOutput = '{"message": {"content": 123}}';
// Should not include non-string content
expect(extractTextGeneric(jsonOutput)).toBe('');
});
});
describe('extractTextFromAgentOutput', () => {
it('should use generic extraction when no parser found', () => {
vi.mocked(getOutputParser).mockReturnValue(null);
const output = '{"result": "Generic result"}';
expect(extractTextFromAgentOutput(output, 'unknown-agent')).toBe('Generic result');
});
it('should return raw output if not JSONL format', () => {
const mockParser = {
parseJsonLine: vi.fn(),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const plainText = 'Plain text output';
expect(extractTextFromAgentOutput(plainText, 'claude-code')).toBe(plainText);
expect(mockParser.parseJsonLine).not.toHaveBeenCalled();
});
it('should use parser to extract result events', () => {
const mockParser = {
parseJsonLine: vi.fn((line: string) => {
const parsed = JSON.parse(line);
if (parsed.type === 'result') {
return { type: 'result', text: parsed.result };
}
return null;
}),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const jsonlOutput = '{"type": "result", "result": "Final answer"}';
expect(extractTextFromAgentOutput(jsonlOutput, 'claude-code')).toBe('Final answer');
});
it('should concatenate text events when no result', () => {
const mockParser = {
parseJsonLine: vi.fn((line: string) => {
try {
const parsed = JSON.parse(line);
if (parsed.type === 'text') {
return { type: 'text', text: parsed.text };
}
} catch {
return null;
}
return null;
}),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const jsonlOutput = [
'{"type": "text", "text": "Part 1"}',
'{"type": "text", "text": "Part 2"}',
].join('\n');
expect(extractTextFromAgentOutput(jsonlOutput, 'claude-code')).toBe('Part 1\nPart 2');
});
it('should prefer result over text events', () => {
const mockParser = {
parseJsonLine: vi.fn((line: string) => {
try {
const parsed = JSON.parse(line);
if (parsed.type === 'result') {
return { type: 'result', text: parsed.result };
}
if (parsed.type === 'text') {
return { type: 'text', text: parsed.text };
}
} catch {
return null;
}
return null;
}),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const jsonlOutput = [
'{"type": "text", "text": "Streaming..."}',
'{"type": "result", "result": "Complete result"}',
].join('\n');
expect(extractTextFromAgentOutput(jsonlOutput, 'claude-code')).toBe('Complete result');
});
it('should skip lines that parser returns null for', () => {
const mockParser = {
parseJsonLine: vi.fn((line: string) => {
try {
const parsed = JSON.parse(line);
if (parsed.type === 'text') {
return { type: 'text', text: parsed.text };
}
} catch {
return null;
}
return null; // Skip other event types
}),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const jsonlOutput = [
'{"type": "system", "data": "ignored"}',
'{"type": "text", "text": "Visible content"}',
'{"type": "tool", "name": "ignored"}',
].join('\n');
expect(extractTextFromAgentOutput(jsonlOutput, 'claude-code')).toBe('Visible content');
});
});
describe('extractTextFromStreamJson', () => {
it('should use agent-specific extraction when agentType provided', () => {
const mockParser = {
parseJsonLine: vi.fn((line: string) => {
const parsed = JSON.parse(line);
return { type: 'result', text: parsed.result };
}),
};
vi.mocked(getOutputParser).mockReturnValue(mockParser as any);
const output = '{"result": "Agent result"}';
expect(extractTextFromStreamJson(output, 'claude-code')).toBe('Agent result');
expect(getOutputParser).toHaveBeenCalledWith('claude-code');
});
it('should use generic extraction when no agentType', () => {
const output = '{"result": "Generic result"}';
expect(extractTextFromStreamJson(output)).toBe('Generic result');
expect(getOutputParser).not.toHaveBeenCalled();
});
it('should use generic extraction when agentType is undefined', () => {
const output = '{"text": "Some text"}';
expect(extractTextFromStreamJson(output, undefined)).toBe('Some text');
expect(getOutputParser).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,164 @@
/**
* @file session-parser.test.ts
* @description Unit tests for group chat session ID parsing utilities.
*/
import { describe, it, expect } from 'vitest';
import { parseParticipantSessionId } from '../../../main/group-chat/session-parser';
describe('group-chat/session-parser', () => {
describe('parseParticipantSessionId', () => {
describe('non-participant session IDs', () => {
it('should return null for regular session IDs', () => {
expect(parseParticipantSessionId('session-abc123')).toBeNull();
});
it('should return null for moderator session IDs', () => {
expect(parseParticipantSessionId('group-chat-abc123-moderator-1702934567890')).toBeNull();
});
it('should return null for empty string', () => {
expect(parseParticipantSessionId('')).toBeNull();
});
it('should return null for session ID containing "participant" but not in correct format', () => {
expect(parseParticipantSessionId('participant-abc123')).toBeNull();
});
});
describe('participant session IDs with UUID suffix', () => {
it('should parse participant session ID with UUID suffix', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-550e8400-e29b-41d4-a716-446655440000'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('Claude');
});
it('should handle hyphenated participant names with UUID suffix', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-OpenCode-Ollama-550e8400-e29b-41d4-a716-446655440000'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('OpenCode-Ollama');
});
it('should handle uppercase UUID', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-550E8400-E29B-41D4-A716-446655440000'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('Claude');
});
it('should handle complex group chat IDs with UUID suffix', () => {
const result = parseParticipantSessionId(
'group-chat-my-complex-chat-id-participant-Agent-550e8400-e29b-41d4-a716-446655440000'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('my-complex-chat-id');
expect(result!.participantName).toBe('Agent');
});
});
describe('participant session IDs with timestamp suffix', () => {
it('should parse participant session ID with timestamp suffix', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-1702934567890'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('Claude');
});
it('should handle hyphenated participant names with timestamp suffix', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-OpenCode-Ollama-1702934567890'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('OpenCode-Ollama');
});
it('should handle long timestamps (14+ digits)', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-17029345678901234'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('Claude');
});
it('should handle complex group chat IDs with timestamp', () => {
const result = parseParticipantSessionId(
'group-chat-my-complex-chat-id-participant-Agent-1702934567890'
);
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('my-complex-chat-id');
expect(result!.participantName).toBe('Agent');
});
});
describe('fallback pattern for backwards compatibility', () => {
it('should handle simple participant names without UUID or long timestamp', () => {
// Fallback pattern for older format
const result = parseParticipantSessionId('group-chat-abc123-participant-Claude-123');
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('abc123');
expect(result!.participantName).toBe('Claude');
});
});
describe('edge cases', () => {
it('should handle participant name with numbers', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-Agent2-1702934567890'
);
expect(result).not.toBeNull();
expect(result!.participantName).toBe('Agent2');
});
it('should handle single character participant name', () => {
const result = parseParticipantSessionId('group-chat-abc123-participant-A-1702934567890');
expect(result).not.toBeNull();
expect(result!.participantName).toBe('A');
});
it('should handle single character group chat ID', () => {
const result = parseParticipantSessionId('group-chat-x-participant-Claude-1702934567890');
expect(result).not.toBeNull();
expect(result!.groupChatId).toBe('x');
});
it('should handle participant name with underscores', () => {
const result = parseParticipantSessionId(
'group-chat-abc123-participant-My_Agent-1702934567890'
);
expect(result).not.toBeNull();
expect(result!.participantName).toBe('My_Agent');
});
});
describe('priority of matching patterns', () => {
it('should prefer UUID match over timestamp match', () => {
// This tests that UUID pattern is tried first
const uuidResult = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-550e8400-e29b-41d4-a716-446655440000'
);
expect(uuidResult).not.toBeNull();
expect(uuidResult!.participantName).toBe('Claude');
});
it('should use timestamp match when UUID pattern does not match', () => {
const timestampResult = parseParticipantSessionId(
'group-chat-abc123-participant-Claude-1702934567890'
);
expect(timestampResult).not.toBeNull();
expect(timestampResult!.participantName).toBe('Claude');
});
});
});
});

View File

@@ -0,0 +1,254 @@
/**
* @file safe-send.test.ts
* @description Unit tests for safe IPC message sending utility.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { BrowserWindow, WebContents } from 'electron';
// Mock the logger
vi.mock('../../../main/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { createSafeSend, type GetMainWindow, type SafeSendFn } from '../../../main/utils/safe-send';
import { logger } from '../../../main/utils/logger';
describe('utils/safe-send', () => {
let mockWebContents: Partial<WebContents>;
let mockWindow: Partial<BrowserWindow>;
let getMainWindow: GetMainWindow;
let safeSend: SafeSendFn;
beforeEach(() => {
vi.clearAllMocks();
// Create mock WebContents
mockWebContents = {
send: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
};
// Create mock BrowserWindow
mockWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: mockWebContents as WebContents,
};
// Default getter returns the mock window
getMainWindow = vi.fn().mockReturnValue(mockWindow as BrowserWindow);
// Create safeSend with the mock
safeSend = createSafeSend(getMainWindow);
});
describe('createSafeSend', () => {
it('should return a function', () => {
expect(typeof createSafeSend(() => null)).toBe('function');
});
it('should create independent safeSend instances', () => {
const window1 = { ...mockWindow } as BrowserWindow;
const window2 = { ...mockWindow } as BrowserWindow;
const safeSend1 = createSafeSend(() => window1);
const safeSend2 = createSafeSend(() => window2);
expect(safeSend1).not.toBe(safeSend2);
});
});
describe('safeSend', () => {
describe('successful sends', () => {
it('should send message to webContents', () => {
safeSend('test-channel', 'arg1', 'arg2');
expect(mockWebContents.send).toHaveBeenCalledWith('test-channel', 'arg1', 'arg2');
});
it('should send message with no arguments', () => {
safeSend('empty-channel');
expect(mockWebContents.send).toHaveBeenCalledWith('empty-channel');
});
it('should send message with complex arguments', () => {
const complexArg = { nested: { data: [1, 2, 3] } };
safeSend('complex-channel', complexArg, null, undefined, 42);
expect(mockWebContents.send).toHaveBeenCalledWith(
'complex-channel',
complexArg,
null,
undefined,
42
);
});
it('should call getMainWindow each time', () => {
safeSend('channel1');
safeSend('channel2');
safeSend('channel3');
expect(getMainWindow).toHaveBeenCalledTimes(3);
});
});
describe('null window handling', () => {
it('should not throw when window is null', () => {
const nullWindowGetter = vi.fn().mockReturnValue(null);
const safeSendNullWindow = createSafeSend(nullWindowGetter);
expect(() => safeSendNullWindow('test-channel', 'data')).not.toThrow();
});
it('should not attempt to send when window is null', () => {
const nullWindowGetter = vi.fn().mockReturnValue(null);
const safeSendNullWindow = createSafeSend(nullWindowGetter);
safeSendNullWindow('test-channel', 'data');
expect(mockWebContents.send).not.toHaveBeenCalled();
});
});
describe('destroyed window handling', () => {
it('should not send when window is destroyed', () => {
vi.mocked(mockWindow.isDestroyed!).mockReturnValue(true);
safeSend('test-channel', 'data');
expect(mockWebContents.send).not.toHaveBeenCalled();
});
it('should not throw when window is destroyed', () => {
vi.mocked(mockWindow.isDestroyed!).mockReturnValue(true);
expect(() => safeSend('test-channel', 'data')).not.toThrow();
});
});
describe('destroyed webContents handling', () => {
it('should not send when webContents is destroyed', () => {
vi.mocked(mockWebContents.isDestroyed!).mockReturnValue(true);
safeSend('test-channel', 'data');
expect(mockWebContents.send).not.toHaveBeenCalled();
});
it('should not throw when webContents is destroyed', () => {
vi.mocked(mockWebContents.isDestroyed!).mockReturnValue(true);
expect(() => safeSend('test-channel', 'data')).not.toThrow();
});
});
describe('missing webContents handling', () => {
it('should not throw when webContents is null', () => {
const windowWithoutWebContents = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: null,
} as unknown as BrowserWindow;
const safeSendNoWebContents = createSafeSend(() => windowWithoutWebContents);
expect(() => safeSendNoWebContents('test-channel', 'data')).not.toThrow();
});
it('should not send when webContents is undefined', () => {
const windowWithUndefinedWebContents = {
isDestroyed: vi.fn().mockReturnValue(false),
webContents: undefined,
} as unknown as BrowserWindow;
const safeSendNoWebContents = createSafeSend(() => windowWithUndefinedWebContents);
safeSendNoWebContents('test-channel', 'data');
expect(mockWebContents.send).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
it('should catch and log errors from send', () => {
const error = new Error('Send failed');
vi.mocked(mockWebContents.send!).mockImplementation(() => {
throw error;
});
expect(() => safeSend('test-channel', 'data')).not.toThrow();
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to send IPC message'),
'IPC',
expect.objectContaining({ error: expect.any(String) })
);
});
it('should catch errors from isDestroyed check', () => {
vi.mocked(mockWindow.isDestroyed!).mockImplementation(() => {
throw new Error('isDestroyed failed');
});
expect(() => safeSend('test-channel', 'data')).not.toThrow();
});
it('should log the channel name in error message', () => {
vi.mocked(mockWebContents.send!).mockImplementation(() => {
throw new Error('Test error');
});
safeSend('my-specific-channel', 'data');
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('my-specific-channel'),
'IPC',
expect.any(Object)
);
});
});
describe('edge cases', () => {
it('should handle rapidly changing window state', () => {
let callCount = 0;
const changingWindowGetter = vi.fn().mockImplementation(() => {
callCount++;
if (callCount % 2 === 0) {
return null;
}
return mockWindow as BrowserWindow;
});
const safeSendChanging = createSafeSend(changingWindowGetter);
// First call - window exists
safeSendChanging('channel1');
expect(mockWebContents.send).toHaveBeenCalledTimes(1);
// Second call - window null
safeSendChanging('channel2');
expect(mockWebContents.send).toHaveBeenCalledTimes(1); // Still 1
// Third call - window exists again
safeSendChanging('channel3');
expect(mockWebContents.send).toHaveBeenCalledTimes(2);
});
it('should handle special channel names', () => {
safeSend('channel:with:colons', 'data');
expect(mockWebContents.send).toHaveBeenCalledWith('channel:with:colons', 'data');
safeSend('channel-with-dashes', 'data');
expect(mockWebContents.send).toHaveBeenCalledWith('channel-with-dashes', 'data');
safeSend('channel_with_underscores', 'data');
expect(mockWebContents.send).toHaveBeenCalledWith('channel_with_underscores', 'data');
});
});
});
});

View File

@@ -0,0 +1,469 @@
/**
* @file web-server-factory.test.ts
* @description Unit tests for web server factory with dependency injection.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { BrowserWindow, WebContents } from 'electron';
// Mock electron
vi.mock('electron', () => ({
ipcMain: {
once: vi.fn(),
},
}));
// Mock WebServer - use class syntax to make it a proper constructor
vi.mock('../../../main/web-server', () => {
return {
WebServer: class MockWebServer {
port: number;
setGetSessionsCallback = vi.fn();
setGetSessionDetailCallback = vi.fn();
setGetThemeCallback = vi.fn();
setGetCustomCommandsCallback = vi.fn();
setGetHistoryCallback = vi.fn();
setWriteToSessionCallback = vi.fn();
setExecuteCommandCallback = vi.fn();
setInterruptSessionCallback = vi.fn();
setSwitchModeCallback = vi.fn();
setSelectSessionCallback = vi.fn();
setSelectTabCallback = vi.fn();
setNewTabCallback = vi.fn();
setCloseTabCallback = vi.fn();
setRenameTabCallback = vi.fn();
constructor(port: number) {
this.port = port;
}
},
};
});
// Mock themes
vi.mock('../../../main/themes', () => ({
getThemeById: vi.fn().mockReturnValue({ id: 'dracula', name: 'Dracula' }),
}));
// Mock history manager
vi.mock('../../../main/history-manager', () => ({
getHistoryManager: vi.fn().mockReturnValue({
getEntries: vi.fn().mockReturnValue([]),
getEntriesByProjectPath: vi.fn().mockReturnValue([]),
getAllEntries: vi.fn().mockReturnValue([]),
}),
}));
// Mock logger
vi.mock('../../../main/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import {
createWebServerFactory,
type WebServerFactoryDependencies,
} from '../../../main/web-server/web-server-factory';
import { WebServer } from '../../../main/web-server';
import { getThemeById } from '../../../main/themes';
import { getHistoryManager } from '../../../main/history-manager';
import { logger } from '../../../main/utils/logger';
describe('web-server/web-server-factory', () => {
let mockSettingsStore: WebServerFactoryDependencies['settingsStore'];
let mockSessionsStore: WebServerFactoryDependencies['sessionsStore'];
let mockGroupsStore: WebServerFactoryDependencies['groupsStore'];
let mockMainWindow: Partial<BrowserWindow>;
let mockWebContents: Partial<WebContents>;
let mockProcessManager: { write: ReturnType<typeof vi.fn> };
let deps: WebServerFactoryDependencies;
beforeEach(() => {
vi.clearAllMocks();
mockSettingsStore = {
get: vi.fn((key: string, defaultValue?: any) => {
const values: Record<string, any> = {
webInterfaceUseCustomPort: false,
webInterfaceCustomPort: 8080,
activeThemeId: 'dracula',
customAICommands: [],
};
return values[key] ?? defaultValue;
}),
};
mockSessionsStore = {
get: vi.fn((key: string, defaultValue?: any) => {
if (key === 'sessions') {
return [
{
id: 'session-1',
name: 'Test Session',
toolType: 'claude-code',
state: 'idle',
inputMode: 'ai',
cwd: '/test/path',
aiTabs: [
{
id: 'tab-1',
logs: [{ source: 'stdout', text: 'Hello', timestamp: Date.now() }],
},
],
activeTabId: 'tab-1',
},
];
}
return defaultValue;
}),
};
mockGroupsStore = {
get: vi.fn((key: string, defaultValue?: any) => {
if (key === 'groups') {
return [{ id: 'group-1', name: 'Test Group', emoji: '🧪' }];
}
return defaultValue;
}),
};
mockWebContents = {
send: vi.fn(),
};
mockMainWindow = {
webContents: mockWebContents as WebContents,
};
mockProcessManager = {
write: vi.fn().mockReturnValue(true),
};
deps = {
settingsStore: mockSettingsStore,
sessionsStore: mockSessionsStore,
groupsStore: mockGroupsStore,
getMainWindow: vi.fn().mockReturnValue(mockMainWindow as BrowserWindow),
getProcessManager: vi.fn().mockReturnValue(mockProcessManager),
};
});
describe('createWebServerFactory', () => {
it('should return a function', () => {
const factory = createWebServerFactory(deps);
expect(typeof factory).toBe('function');
});
it('should create a WebServer when called', () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
expect(server).toBeDefined();
expect(server).toBeInstanceOf(WebServer);
});
it('should use random port (0) when custom port is disabled', () => {
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'webInterfaceUseCustomPort') return false;
if (key === 'webInterfaceCustomPort') return 9999;
return defaultValue;
});
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
// Check that the server was created with port 0 (random)
expect((server as any).port).toBe(0);
});
it('should use custom port when enabled', () => {
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'webInterfaceUseCustomPort') return true;
if (key === 'webInterfaceCustomPort') return 9999;
return defaultValue;
});
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
// Check that the server was created with custom port
expect((server as any).port).toBe(9999);
});
});
describe('callback registrations', () => {
let createWebServer: ReturnType<typeof createWebServerFactory>;
let server: ReturnType<typeof createWebServer>;
beforeEach(() => {
createWebServer = createWebServerFactory(deps);
server = createWebServer();
});
it('should register getSessionsCallback', () => {
expect(server.setGetSessionsCallback).toHaveBeenCalled();
});
it('should register getSessionDetailCallback', () => {
expect(server.setGetSessionDetailCallback).toHaveBeenCalled();
});
it('should register getThemeCallback', () => {
expect(server.setGetThemeCallback).toHaveBeenCalled();
});
it('should register getCustomCommandsCallback', () => {
expect(server.setGetCustomCommandsCallback).toHaveBeenCalled();
});
it('should register getHistoryCallback', () => {
expect(server.setGetHistoryCallback).toHaveBeenCalled();
});
it('should register writeToSessionCallback', () => {
expect(server.setWriteToSessionCallback).toHaveBeenCalled();
});
it('should register executeCommandCallback', () => {
expect(server.setExecuteCommandCallback).toHaveBeenCalled();
});
it('should register interruptSessionCallback', () => {
expect(server.setInterruptSessionCallback).toHaveBeenCalled();
});
it('should register switchModeCallback', () => {
expect(server.setSwitchModeCallback).toHaveBeenCalled();
});
it('should register selectSessionCallback', () => {
expect(server.setSelectSessionCallback).toHaveBeenCalled();
});
it('should register tab operation callbacks', () => {
expect(server.setSelectTabCallback).toHaveBeenCalled();
expect(server.setNewTabCallback).toHaveBeenCalled();
expect(server.setCloseTabCallback).toHaveBeenCalled();
expect(server.setRenameTabCallback).toHaveBeenCalled();
});
});
describe('getSessionsCallback behavior', () => {
it('should return sessions with mapped data', () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
// Get the callback that was registered
const setGetSessionsCallback = server.setGetSessionsCallback as ReturnType<typeof vi.fn>;
const callback = setGetSessionsCallback.mock.calls[0][0];
const sessions = callback();
expect(Array.isArray(sessions)).toBe(true);
expect(sessions.length).toBeGreaterThan(0);
expect(sessions[0]).toHaveProperty('id');
expect(sessions[0]).toHaveProperty('name');
expect(sessions[0]).toHaveProperty('toolType');
});
});
describe('writeToSessionCallback behavior', () => {
it('should return false when processManager is null', () => {
deps.getProcessManager = vi.fn().mockReturnValue(null);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setWriteCallback = server.setWriteToSessionCallback as ReturnType<typeof vi.fn>;
const callback = setWriteCallback.mock.calls[0][0];
const result = callback('session-1', 'test data');
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it('should return false when session not found', () => {
vi.mocked(mockSessionsStore.get).mockReturnValue([]);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setWriteCallback = server.setWriteToSessionCallback as ReturnType<typeof vi.fn>;
const callback = setWriteCallback.mock.calls[0][0];
const result = callback('non-existent-session', 'test data');
expect(result).toBe(false);
});
it('should write to AI process when inputMode is ai', () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setWriteCallback = server.setWriteToSessionCallback as ReturnType<typeof vi.fn>;
const callback = setWriteCallback.mock.calls[0][0];
callback('session-1', 'test data');
expect(mockProcessManager.write).toHaveBeenCalledWith('session-1-ai', 'test data');
});
});
describe('executeCommandCallback behavior', () => {
it('should return false when mainWindow is null', async () => {
deps.getMainWindow = vi.fn().mockReturnValue(null);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setExecuteCallback = server.setExecuteCommandCallback as ReturnType<typeof vi.fn>;
const callback = setExecuteCallback.mock.calls[0][0];
const result = await callback('session-1', 'test command');
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it('should send command to renderer', async () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setExecuteCallback = server.setExecuteCommandCallback as ReturnType<typeof vi.fn>;
const callback = setExecuteCallback.mock.calls[0][0];
const result = await callback('session-1', 'test command', 'ai');
expect(result).toBe(true);
expect(mockWebContents.send).toHaveBeenCalledWith(
'remote:executeCommand',
'session-1',
'test command',
'ai'
);
});
});
describe('interruptSessionCallback behavior', () => {
it('should return false when mainWindow is null', async () => {
deps.getMainWindow = vi.fn().mockReturnValue(null);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setInterruptCallback = server.setInterruptSessionCallback as ReturnType<typeof vi.fn>;
const callback = setInterruptCallback.mock.calls[0][0];
const result = await callback('session-1');
expect(result).toBe(false);
});
it('should send interrupt to renderer', async () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setInterruptCallback = server.setInterruptSessionCallback as ReturnType<typeof vi.fn>;
const callback = setInterruptCallback.mock.calls[0][0];
const result = await callback('session-1');
expect(result).toBe(true);
expect(mockWebContents.send).toHaveBeenCalledWith('remote:interrupt', 'session-1');
});
});
describe('switchModeCallback behavior', () => {
it('should send mode switch to renderer', async () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setSwitchModeCallback = server.setSwitchModeCallback as ReturnType<typeof vi.fn>;
const callback = setSwitchModeCallback.mock.calls[0][0];
const result = await callback('session-1', 'terminal');
expect(result).toBe(true);
expect(mockWebContents.send).toHaveBeenCalledWith(
'remote:switchMode',
'session-1',
'terminal'
);
});
});
describe('getThemeCallback behavior', () => {
it('should return theme from getThemeById', () => {
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setThemeCallback = server.setGetThemeCallback as ReturnType<typeof vi.fn>;
const callback = setThemeCallback.mock.calls[0][0];
const theme = callback();
expect(getThemeById).toHaveBeenCalled();
expect(theme).toEqual({ id: 'dracula', name: 'Dracula' });
});
});
describe('getHistoryCallback behavior', () => {
it('should get entries for specific session', () => {
const mockHistoryManager = {
getEntries: vi.fn().mockReturnValue([{ id: 1 }]),
getEntriesByProjectPath: vi.fn(),
getAllEntries: vi.fn(),
};
vi.mocked(getHistoryManager).mockReturnValue(mockHistoryManager as any);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setHistoryCallback = server.setGetHistoryCallback as ReturnType<typeof vi.fn>;
const callback = setHistoryCallback.mock.calls[0][0];
callback(undefined, 'session-1');
expect(mockHistoryManager.getEntries).toHaveBeenCalledWith('session-1');
});
it('should get entries by project path', () => {
const mockHistoryManager = {
getEntries: vi.fn(),
getEntriesByProjectPath: vi.fn().mockReturnValue([{ id: 1 }]),
getAllEntries: vi.fn(),
};
vi.mocked(getHistoryManager).mockReturnValue(mockHistoryManager as any);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setHistoryCallback = server.setGetHistoryCallback as ReturnType<typeof vi.fn>;
const callback = setHistoryCallback.mock.calls[0][0];
callback('/test/project');
expect(mockHistoryManager.getEntriesByProjectPath).toHaveBeenCalledWith('/test/project');
});
it('should get all entries when no filter', () => {
const mockHistoryManager = {
getEntries: vi.fn(),
getEntriesByProjectPath: vi.fn(),
getAllEntries: vi.fn().mockReturnValue([{ id: 1 }]),
};
vi.mocked(getHistoryManager).mockReturnValue(mockHistoryManager as any);
const createWebServer = createWebServerFactory(deps);
const server = createWebServer();
const setHistoryCallback = server.setGetHistoryCallback as ReturnType<typeof vi.fn>;
const callback = setHistoryCallback.mock.calls[0][0];
callback();
expect(mockHistoryManager.getAllEntries).toHaveBeenCalled();
});
});
});

View File

@@ -2,12 +2,45 @@
* Main process constants
*
* Centralized constants used across the main process for Claude session parsing,
* API pricing, and demo mode detection.
* API pricing, demo mode detection, and pre-compiled regex patterns.
*/
import path from 'path';
import os from 'os';
// ============================================================================
// Pre-compiled Regex Patterns (Performance Optimization)
// ============================================================================
// These patterns are used in hot paths (process data handlers) that fire hundreds
// of times per second. Pre-compiling them avoids repeated regex compilation overhead.
// Group chat session ID patterns
export const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/;
export const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/;
export const REGEX_PARTICIPANT_UUID =
/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
export const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/;
export const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/;
// Web broadcast session ID patterns
export const REGEX_AI_SUFFIX = /-ai-[^-]+$/;
export const REGEX_AI_TAB_ID = /-ai-([^-]+)$/;
// ============================================================================
// Debug Logging (Performance Optimization)
// ============================================================================
// Debug logs in hot paths (data handlers) are disabled in production to avoid
// performance overhead from string interpolation and console I/O on every data chunk.
export const DEBUG_GROUP_CHAT =
process.env.NODE_ENV === 'development' || process.env.DEBUG_GROUP_CHAT === '1';
/** Log debug message only in development mode. Avoids overhead in production. */
export function debugLog(prefix: string, message: string, ...args: unknown[]): void {
if (DEBUG_GROUP_CHAT) {
console.log(`[${prefix}] ${message}`, ...args);
}
}
/**
* Demo mode flag - enables isolated data directory for fresh demos
* Activated via --demo CLI flag or MAESTRO_DEMO_DIR environment variable

View File

@@ -0,0 +1,40 @@
/**
* Output buffer management for group chat.
* Buffers streaming output from group chat processes and releases on process exit.
*/
// Buffer for group chat output (keyed by sessionId)
// We buffer output and only route it on process exit to avoid duplicate messages from streaming chunks
// Uses array of chunks for O(1) append performance instead of O(n) string concatenation
// Tracks totalLength incrementally to avoid O(n) reduce on every append
const groupChatOutputBuffers = new Map<string, { chunks: string[]; totalLength: number }>();
/** Append data to group chat output buffer. O(1) operation. */
export function appendToGroupChatBuffer(sessionId: string, data: string): number {
let buffer = groupChatOutputBuffers.get(sessionId);
if (!buffer) {
buffer = { chunks: [], totalLength: 0 };
groupChatOutputBuffers.set(sessionId, buffer);
}
buffer.chunks.push(data);
buffer.totalLength += data.length;
return buffer.totalLength;
}
/** Get buffered output as a single string. Joins chunks on read. */
export function getGroupChatBufferedOutput(sessionId: string): string | undefined {
const buffer = groupChatOutputBuffers.get(sessionId);
if (!buffer || buffer.chunks.length === 0) return undefined;
return buffer.chunks.join('');
}
/** Clear the buffer for a session. Call after processing buffered output. */
export function clearGroupChatBuffer(sessionId: string): void {
groupChatOutputBuffers.delete(sessionId);
}
/** Check if a session has buffered output. */
export function hasGroupChatBuffer(sessionId: string): boolean {
const buffer = groupChatOutputBuffers.get(sessionId);
return buffer !== undefined && buffer.chunks.length > 0;
}

View File

@@ -0,0 +1,133 @@
/**
* Output parsing utilities for group chat.
* Extracts text content from agent JSON/JSONL output formats.
*/
import { getOutputParser } from '../parsers';
import { logger } from '../utils/logger';
/**
* Generic text extraction fallback for unknown agent types.
* Tries common patterns for JSON output.
*/
export function extractTextGeneric(rawOutput: string): string {
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text)
const firstNonEmptyLine = lines.find((line) => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
return rawOutput;
}
const textParts: string[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
// Try common patterns
if (msg.result) return msg.result;
if (msg.text) textParts.push(msg.text);
if (msg.part?.text) textParts.push(msg.part.text);
if (msg.message?.content) {
const content = msg.message.content;
if (typeof content === 'string') {
textParts.push(content);
}
}
} catch {
// Not valid JSON - include raw text if it looks like content
if (!line.startsWith('{') && !line.includes('session_id') && !line.includes('sessionID')) {
textParts.push(line);
}
}
}
// Join with newlines to preserve paragraph structure
return textParts.join('\n');
}
/**
* Extract text content from agent JSON output format.
* Uses the registered output parser for the given agent type.
* Different agents have different output formats:
* - Claude: { type: 'result', result: '...' } and { type: 'assistant', message: { content: ... } }
* - OpenCode: { type: 'text', part: { text: '...' } } and { type: 'step_finish', part: { reason: 'stop' } }
*
* @param rawOutput - The raw JSONL output from the agent
* @param agentType - The agent type (e.g., 'claude-code', 'opencode')
* @returns Extracted text content
*/
export function extractTextFromAgentOutput(rawOutput: string, agentType: string): string {
const parser = getOutputParser(agentType);
// If no parser found, try a generic extraction
if (!parser) {
logger.warn(
`No parser found for agent type '${agentType}', using generic extraction`,
'[GroupChat]'
);
return extractTextGeneric(rawOutput);
}
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text from process-manager)
const firstNonEmptyLine = lines.find((line) => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
logger.debug(
`[GroupChat] Input is not JSONL, returning as plain text (len=${rawOutput.length})`,
'[GroupChat]'
);
return rawOutput;
}
const textParts: string[] = [];
let resultText: string | null = null;
let _resultMessageCount = 0;
let _textMessageCount = 0;
for (const line of lines) {
if (!line.trim()) continue;
const event = parser.parseJsonLine(line);
if (!event) continue;
// Extract text based on event type
if (event.type === 'result' && event.text) {
// Result message is the authoritative final response - save it
resultText = event.text;
_resultMessageCount++;
}
if (event.type === 'text' && event.text) {
textParts.push(event.text);
_textMessageCount++;
}
}
// Prefer result message if available (it contains the complete formatted response)
if (resultText) {
return resultText;
}
// Fallback: if no result message, concatenate streaming text parts with newlines
// to preserve paragraph structure from partial streaming events
return textParts.join('\n');
}
/**
* Extract text content from stream-json output (JSONL).
* Uses the agent-specific parser when the agent type is known.
*/
export function extractTextFromStreamJson(rawOutput: string, agentType?: string): string {
if (agentType) {
return extractTextFromAgentOutput(rawOutput, agentType);
}
return extractTextGeneric(rawOutput);
}

View File

@@ -0,0 +1,51 @@
/**
* Session ID parsing utilities for group chat.
* Extracts groupChatId and participantName from session IDs.
*/
import {
REGEX_PARTICIPANT_UUID,
REGEX_PARTICIPANT_TIMESTAMP,
REGEX_PARTICIPANT_FALLBACK,
} from '../constants';
/**
* Parses a group chat participant session ID to extract groupChatId and participantName.
* Handles hyphenated participant names by matching against UUID or timestamp suffixes.
*
* Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
* Examples:
* - group-chat-abc123-participant-Claude-1702934567890
* - group-chat-abc123-participant-OpenCode-Ollama-550e8400-e29b-41d4-a716-446655440000
*
* @returns null if not a participant session ID, otherwise { groupChatId, participantName }
*/
export function parseParticipantSessionId(
sessionId: string
): { groupChatId: string; participantName: string } | null {
// First check if this is a participant session ID at all
if (!sessionId.includes('-participant-')) {
return null;
}
// Try matching with UUID suffix first (36 chars: 8-4-4-4-12 format)
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID);
if (uuidMatch) {
return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] };
}
// Try matching with timestamp suffix (13 digits)
const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
if (timestampMatch) {
return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] };
}
// Fallback: try the old pattern for backwards compatibility (non-hyphenated names)
const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK);
if (fallbackMatch) {
return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] };
}
return null;
}

View File

@@ -10,7 +10,6 @@ import { AgentDetector } from './agent-detector';
import { logger } from './utils/logger';
import { tunnelManager } from './tunnel-manager';
import { powerManager } from './power-manager';
import { getThemeById } from './themes';
import { getHistoryManager } from './history-manager';
import {
initializeStores,
@@ -69,45 +68,30 @@ import {
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery';
import { initializeSessionStorages } from './storage';
import { initializeOutputParsers, getOutputParser } from './parsers';
import { initializeOutputParsers } from './parsers';
import { calculateContextTokens } from './parsers/usage-aggregator';
import { DEMO_MODE, DEMO_DATA_PATH } from './constants';
import {
DEMO_MODE,
DEMO_DATA_PATH,
REGEX_MODERATOR_SESSION,
REGEX_MODERATOR_SESSION_TIMESTAMP,
REGEX_AI_SUFFIX,
REGEX_AI_TAB_ID,
debugLog,
} from './constants';
import { initAutoUpdater } from './auto-updater';
import { checkWslEnvironment } from './utils/wslDetector';
// ============================================================================
// Pre-compiled Regex Patterns (Performance Optimization)
// ============================================================================
// These patterns are used in hot paths (process data handlers) that fire hundreds
// of times per second. Pre-compiling them avoids repeated regex compilation overhead.
// Group chat session ID patterns
const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/;
const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/;
const REGEX_PARTICIPANT_UUID =
/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/;
const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/;
// Web broadcast session ID patterns
const REGEX_AI_SUFFIX = /-ai-[^-]+$/;
const REGEX_AI_TAB_ID = /-ai-([^-]+)$/;
// ============================================================================
// Debug Logging (Performance Optimization)
// ============================================================================
// Debug logs in hot paths (data handlers) are disabled in production to avoid
// performance overhead from string interpolation and console I/O on every data chunk.
const DEBUG_GROUP_CHAT =
process.env.NODE_ENV === 'development' || process.env.DEBUG_GROUP_CHAT === '1';
/** Log debug message only in development mode. Avoids overhead in production. */
function debugLog(prefix: string, message: string, ...args: any[]): void {
if (DEBUG_GROUP_CHAT) {
console.log(`[${prefix}] ${message}`, ...args);
}
}
// Extracted modules (Phase 1 refactoring)
import { parseParticipantSessionId } from './group-chat/session-parser';
import { extractTextFromStreamJson } from './group-chat/output-parser';
import {
appendToGroupChatBuffer,
getGroupChatBufferedOutput,
clearGroupChatBuffer,
} from './group-chat/output-buffer';
// Phase 2 refactoring - dependency injection
import { createSafeSend } from './utils/safe-send';
import { createWebServerFactory } from './web-server/web-server-factory';
// ============================================================================
// Data Directory Configuration (MUST happen before any Store initialization)
@@ -216,361 +200,17 @@ let webServer: WebServer | null = null;
let agentDetector: AgentDetector | null = null;
let cliActivityWatcher: fsSync.FSWatcher | null = null;
/**
* Safely send IPC message to renderer.
* Handles cases where the renderer has been disposed (e.g., GPU crash, window closing).
* This prevents "Render frame was disposed before WebFrameMain could be accessed" errors.
*/
function safeSend(channel: string, ...args: unknown[]): void {
try {
if (
mainWindow &&
!mainWindow.isDestroyed() &&
mainWindow.webContents &&
!mainWindow.webContents.isDestroyed()
) {
mainWindow.webContents.send(channel, ...args);
}
} catch (error) {
// Silently ignore - renderer is not available
// This can happen during GPU crashes, window closing, or app shutdown
logger.debug(`Failed to send IPC message to renderer: ${channel}`, 'IPC', {
error: String(error),
});
}
}
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
/**
* Create and configure the web server with all necessary callbacks.
* Called when user enables the web interface.
*/
function createWebServer(): WebServer {
// Use custom port if enabled, otherwise 0 for random port assignment
const useCustomPort = store.get('webInterfaceUseCustomPort', false);
const customPort = store.get('webInterfaceCustomPort', 8080);
const port = useCustomPort ? customPort : 0;
const server = new WebServer(port); // Custom or random port with auto-generated security token
// Set up callback for web server to fetch sessions list
server.setGetSessionsCallback(() => {
const sessions = sessionsStore.get('sessions', []);
const groups = groupsStore.get('groups', []);
return sessions.map((s: any) => {
// Find the group for this session
const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null;
// Extract last AI response for mobile preview (first 3 lines, max 500 chars)
// Use active tab's logs as the source of truth
let lastResponse = null;
const activeTab = s.aiTabs?.find((t: any) => t.id === s.activeTabId) || s.aiTabs?.[0];
const tabLogs = activeTab?.logs || [];
if (tabLogs.length > 0) {
// Find the last stdout/stderr entry from the AI (not user messages)
// Note: 'thinking' logs are already excluded since they have a distinct source type
const lastAiLog = [...tabLogs]
.reverse()
.find((log: any) => log.source === 'stdout' || log.source === 'stderr');
if (lastAiLog && lastAiLog.text) {
const fullText = lastAiLog.text;
// Get first 3 lines or 500 chars, whichever is shorter
const lines = fullText.split('\n').slice(0, 3);
let previewText = lines.join('\n');
if (previewText.length > 500) {
previewText = previewText.slice(0, 497) + '...';
} else if (fullText.length > previewText.length) {
previewText = previewText + '...';
}
lastResponse = {
text: previewText,
timestamp: lastAiLog.timestamp,
source: lastAiLog.source,
fullLength: fullText.length,
};
}
}
// Map aiTabs to web-safe format (strip logs to reduce payload)
const aiTabs =
s.aiTabs?.map((tab: any) => ({
id: tab.id,
agentSessionId: tab.agentSessionId || null,
name: tab.name || null,
starred: tab.starred || false,
inputValue: tab.inputValue || '',
usageStats: tab.usageStats || null,
createdAt: tab.createdAt,
state: tab.state || 'idle',
thinkingStartTime: tab.thinkingStartTime || null,
})) || [];
return {
id: s.id,
name: s.name,
toolType: s.toolType,
state: s.state,
inputMode: s.inputMode,
cwd: s.cwd,
groupId: s.groupId || null,
groupName: group?.name || null,
groupEmoji: group?.emoji || null,
usageStats: s.usageStats || null,
lastResponse,
agentSessionId: s.agentSessionId || null,
thinkingStartTime: s.thinkingStartTime || null,
aiTabs,
activeTabId: s.activeTabId || (aiTabs.length > 0 ? aiTabs[0].id : undefined),
bookmarked: s.bookmarked || false,
// Worktree subagent support
parentSessionId: s.parentSessionId || null,
worktreeBranch: s.worktreeBranch || null,
};
});
});
// Set up callback for web server to fetch single session details
// Optional tabId param allows fetching logs for a specific tab (avoids race conditions)
server.setGetSessionDetailCallback((sessionId: string, tabId?: string) => {
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) return null;
// Get the requested tab's logs (or active tab if no tabId provided)
// Tabs are the source of truth for AI conversation history
// Filter out thinking and tool logs - these should never be shown on the web interface
let aiLogs: any[] = [];
const targetTabId = tabId || session.activeTabId;
if (session.aiTabs && session.aiTabs.length > 0) {
const targetTab = session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0];
const rawLogs = targetTab?.logs || [];
// Web interface should never show thinking/tool logs regardless of desktop settings
aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool');
}
return {
id: session.id,
name: session.name,
toolType: session.toolType,
state: session.state,
inputMode: session.inputMode,
cwd: session.cwd,
aiLogs,
shellLogs: session.shellLogs || [],
usageStats: session.usageStats,
agentSessionId: session.agentSessionId,
isGitRepo: session.isGitRepo,
activeTabId: targetTabId,
};
});
// Set up callback for web server to fetch current theme
server.setGetThemeCallback(() => {
const themeId = store.get('activeThemeId', 'dracula');
return getThemeById(themeId);
});
// Set up callback for web server to fetch custom AI commands
server.setGetCustomCommandsCallback(() => {
const customCommands = store.get('customAICommands', []) as Array<{
id: string;
command: string;
description: string;
prompt: string;
}>;
return customCommands;
});
// Set up callback for web server to fetch history entries
// Uses HistoryManager for per-session storage
server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => {
const historyManager = getHistoryManager();
if (sessionId) {
// Get entries for specific session
const entries = historyManager.getEntries(sessionId);
// Sort by timestamp descending
entries.sort((a, b) => b.timestamp - a.timestamp);
return entries;
}
if (projectPath) {
// Get all entries for sessions in this project
return historyManager.getEntriesByProjectPath(projectPath);
}
// Return all entries (for global view)
return historyManager.getAllEntries();
});
// Set up callback for web server to write commands to sessions
// Note: Process IDs have -ai or -terminal suffix based on session's inputMode
server.setWriteToSessionCallback((sessionId: string, data: string) => {
if (!processManager) {
logger.warn('processManager is null for writeToSession', 'WebServer');
return false;
}
// Get the session's current inputMode to determine which process to write to
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer');
return false;
}
// Append -ai or -terminal suffix based on inputMode
const targetSessionId =
session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`;
logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer');
const result = processManager.write(targetSessionId, data);
logger.debug(`Write result: ${result}`, 'WebServer');
return result;
});
// Set up callback for web server to execute commands through the desktop
// This forwards AI commands to the renderer, ensuring single source of truth
// The renderer handles all spawn logic, state management, and broadcasts
server.setExecuteCommandCallback(
async (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => {
if (!mainWindow) {
logger.warn('mainWindow is null for executeCommand', 'WebServer');
return false;
}
// Look up the session to get Claude session ID for logging
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
const agentSessionId = session?.agentSessionId || 'none';
// Forward to renderer - it will handle spawn, state, and everything else
// This ensures web commands go through exact same code path as desktop commands
// Pass inputMode so renderer uses the web's intended mode (avoids sync issues)
logger.info(
`[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${agentSessionId} | Mode: ${inputMode || 'auto'} | Command: ${command.substring(0, 100)}`,
'WebServer'
);
mainWindow.webContents.send('remote:executeCommand', sessionId, command, inputMode);
return true;
}
);
// Set up callback for web server to interrupt sessions through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setInterruptSessionCallback(async (sessionId: string) => {
if (!mainWindow) {
logger.warn('mainWindow is null for interrupt', 'WebServer');
return false;
}
// Forward to renderer - it will handle interrupt, state update, and broadcasts
// This ensures web interrupts go through exact same code path as desktop interrupts
logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer');
mainWindow.webContents.send('remote:interrupt', sessionId);
return true;
});
// Set up callback for web server to switch session mode through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => {
logger.info(
`[Web→Desktop] Mode switch callback invoked: session=${sessionId}, mode=${mode}`,
'WebServer'
);
if (!mainWindow) {
logger.warn('mainWindow is null for switchMode', 'WebServer');
return false;
}
// Forward to renderer - it will handle mode switch and broadcasts
// This ensures web mode switches go through exact same code path as desktop
logger.info(`[Web→Desktop] Sending IPC remote:switchMode to renderer`, 'WebServer');
mainWindow.webContents.send('remote:switchMode', sessionId, mode);
return true;
});
// Set up callback for web server to select/switch to a session in the desktop
// This forwards to the renderer which handles state updates and broadcasts
// If tabId is provided, also switches to that tab within the session
server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => {
logger.info(
`[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`,
'WebServer'
);
if (!mainWindow) {
logger.warn('mainWindow is null for selectSession', 'WebServer');
return false;
}
// Forward to renderer - it will handle session selection and broadcasts
logger.info(`[Web→Desktop] Sending IPC remote:selectSession to renderer`, 'WebServer');
mainWindow.webContents.send('remote:selectSession', sessionId, tabId);
return true;
});
// Tab operation callbacks
server.setSelectTabCallback(async (sessionId: string, tabId: string) => {
logger.info(
`[Web→Desktop] Tab select callback invoked: session=${sessionId}, tab=${tabId}`,
'WebServer'
);
if (!mainWindow) {
logger.warn('mainWindow is null for selectTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:selectTab', sessionId, tabId);
return true;
});
server.setNewTabCallback(async (sessionId: string) => {
logger.info(`[Web→Desktop] New tab callback invoked: session=${sessionId}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for newTab', 'WebServer');
return null;
}
// Use invoke for synchronous response with tab ID
return new Promise((resolve) => {
const responseChannel = `remote:newTab:response:${Date.now()}`;
ipcMain.once(responseChannel, (_event, result) => {
resolve(result);
});
mainWindow!.webContents.send('remote:newTab', sessionId, responseChannel);
// Timeout after 5 seconds
setTimeout(() => resolve(null), 5000);
});
});
server.setCloseTabCallback(async (sessionId: string, tabId: string) => {
logger.info(
`[Web→Desktop] Close tab callback invoked: session=${sessionId}, tab=${tabId}`,
'WebServer'
);
if (!mainWindow) {
logger.warn('mainWindow is null for closeTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:closeTab', sessionId, tabId);
return true;
});
server.setRenameTabCallback(async (sessionId: string, tabId: string, newName: string) => {
logger.info(
`[Web→Desktop] Rename tab callback invoked: session=${sessionId}, tab=${tabId}, newName=${newName}`,
'WebServer'
);
if (!mainWindow) {
logger.warn('mainWindow is null for renameTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:renameTab', sessionId, tabId, newName);
return true;
});
return server;
}
// Create web server factory with dependency injection (Phase 2 refactoring)
const createWebServer = createWebServerFactory({
settingsStore: store,
sessionsStore,
groupsStore,
getMainWindow: () => mainWindow,
getProcessManager: () => processManager,
});
function createWindow() {
// Restore saved window state
@@ -1168,198 +808,6 @@ function setupIpcHandlers() {
});
}
// Buffer for group chat output (keyed by sessionId)
// We buffer output and only route it on process exit to avoid duplicate messages from streaming chunks
// Uses array of chunks for O(1) append performance instead of O(n) string concatenation
// Tracks totalLength incrementally to avoid O(n) reduce on every append
const groupChatOutputBuffers = new Map<string, { chunks: string[]; totalLength: number }>();
/** Append data to group chat output buffer. O(1) operation. */
function appendToGroupChatBuffer(sessionId: string, data: string): number {
let buffer = groupChatOutputBuffers.get(sessionId);
if (!buffer) {
buffer = { chunks: [], totalLength: 0 };
groupChatOutputBuffers.set(sessionId, buffer);
}
buffer.chunks.push(data);
buffer.totalLength += data.length;
return buffer.totalLength;
}
/** Get buffered output as a single string. Joins chunks on read. */
function getGroupChatBufferedOutput(sessionId: string): string | undefined {
const buffer = groupChatOutputBuffers.get(sessionId);
if (!buffer || buffer.chunks.length === 0) return undefined;
return buffer.chunks.join('');
}
/**
* Extract text content from agent JSON output format.
* Uses the registered output parser for the given agent type.
* Different agents have different output formats:
* - Claude: { type: 'result', result: '...' } and { type: 'assistant', message: { content: ... } }
* - OpenCode: { type: 'text', part: { text: '...' } } and { type: 'step_finish', part: { reason: 'stop' } }
*
* @param rawOutput - The raw JSONL output from the agent
* @param agentType - The agent type (e.g., 'claude-code', 'opencode')
* @returns Extracted text content
*/
function extractTextFromAgentOutput(rawOutput: string, agentType: string): string {
const parser = getOutputParser(agentType);
// If no parser found, try a generic extraction
if (!parser) {
logger.warn(
`No parser found for agent type '${agentType}', using generic extraction`,
'[GroupChat]'
);
return extractTextGeneric(rawOutput);
}
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text from process-manager)
const firstNonEmptyLine = lines.find((line) => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
logger.debug(
`[GroupChat] Input is not JSONL, returning as plain text (len=${rawOutput.length})`,
'[GroupChat]'
);
return rawOutput;
}
const textParts: string[] = [];
let resultText: string | null = null;
let _resultMessageCount = 0;
let _textMessageCount = 0;
for (const line of lines) {
if (!line.trim()) continue;
const event = parser.parseJsonLine(line);
if (!event) continue;
// Extract text based on event type
if (event.type === 'result' && event.text) {
// Result message is the authoritative final response - save it
resultText = event.text;
_resultMessageCount++;
}
if (event.type === 'text' && event.text) {
textParts.push(event.text);
_textMessageCount++;
}
}
// Prefer result message if available (it contains the complete formatted response)
if (resultText) {
return resultText;
}
// Fallback: if no result message, concatenate streaming text parts with newlines
// to preserve paragraph structure from partial streaming events
return textParts.join('\n');
}
/**
* Extract text content from stream-json output (JSONL).
* Uses the agent-specific parser when the agent type is known.
*/
function extractTextFromStreamJson(rawOutput: string, agentType?: string): string {
if (agentType) {
return extractTextFromAgentOutput(rawOutput, agentType);
}
return extractTextGeneric(rawOutput);
}
/**
* Generic text extraction fallback for unknown agent types.
* Tries common patterns for JSON output.
*/
function extractTextGeneric(rawOutput: string): string {
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text)
const firstNonEmptyLine = lines.find((line) => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
return rawOutput;
}
const textParts: string[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
// Try common patterns
if (msg.result) return msg.result;
if (msg.text) textParts.push(msg.text);
if (msg.part?.text) textParts.push(msg.part.text);
if (msg.message?.content) {
const content = msg.message.content;
if (typeof content === 'string') {
textParts.push(content);
}
}
} catch {
// Not valid JSON - include raw text if it looks like content
if (!line.startsWith('{') && !line.includes('session_id') && !line.includes('sessionID')) {
textParts.push(line);
}
}
}
// Join with newlines to preserve paragraph structure
return textParts.join('\n');
}
/**
* Parses a group chat participant session ID to extract groupChatId and participantName.
* Handles hyphenated participant names by matching against UUID or timestamp suffixes.
*
* Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
* Examples:
* - group-chat-abc123-participant-Claude-1702934567890
* - group-chat-abc123-participant-OpenCode-Ollama-550e8400-e29b-41d4-a716-446655440000
*
* @returns null if not a participant session ID, otherwise { groupChatId, participantName }
*/
function parseParticipantSessionId(
sessionId: string
): { groupChatId: string; participantName: string } | null {
// First check if this is a participant session ID at all
if (!sessionId.includes('-participant-')) {
return null;
}
// Try matching with UUID suffix first (36 chars: 8-4-4-4-12 format)
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID);
if (uuidMatch) {
return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] };
}
// Try matching with timestamp suffix (13 digits)
const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP);
if (timestampMatch) {
return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] };
}
// Fallback: try the old pattern for backwards compatibility (non-hyphenated names)
const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK);
if (fallbackMatch) {
return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] };
}
return null;
}
// Handle process output streaming (set up after initialization)
function setupProcessListeners() {
if (processManager) {
@@ -1547,7 +995,7 @@ function setupProcessListeners() {
}
}
})().finally(() => {
groupChatOutputBuffers.delete(sessionId);
clearGroupChatBuffer(sessionId);
debugLog('GroupChat:Debug', ` Cleared output buffer for session`);
});
} else {
@@ -1641,7 +1089,7 @@ function setupProcessListeners() {
});
// Clear the buffer first
groupChatOutputBuffers.delete(sessionId);
clearGroupChatBuffer(sessionId);
// Initiate recovery (clears agentSessionId)
await initiateSessionRecovery(groupChatId, participantName);
@@ -1746,7 +1194,7 @@ function setupProcessListeners() {
}
}
})().finally(() => {
groupChatOutputBuffers.delete(sessionId);
clearGroupChatBuffer(sessionId);
debugLog('GroupChat:Debug', ` Cleared output buffer for participant session`);
// Mark participant and trigger synthesis AFTER logging is complete
markAndMaybeSynthesize();

View File

@@ -0,0 +1,47 @@
/**
* Safe IPC message sending utility.
* Handles cases where the renderer has been disposed.
*/
import { BrowserWindow } from 'electron';
import { logger } from './logger';
/** Function type for getting the main window reference */
export type GetMainWindow = () => BrowserWindow | null;
/**
* Creates a safeSend function with the provided window getter.
* This allows dependency injection of the window reference.
*
* @param getMainWindow - Function that returns the current main window or null
* @returns A function that safely sends IPC messages to the renderer
*/
export function createSafeSend(getMainWindow: GetMainWindow) {
/**
* Safely send IPC message to renderer.
* Handles cases where the renderer has been disposed (e.g., GPU crash, window closing).
* This prevents "Render frame was disposed before WebFrameMain could be accessed" errors.
*/
return function safeSend(channel: string, ...args: unknown[]): void {
try {
const mainWindow = getMainWindow();
if (
mainWindow &&
!mainWindow.isDestroyed() &&
mainWindow.webContents &&
!mainWindow.webContents.isDestroyed()
) {
mainWindow.webContents.send(channel, ...args);
}
} catch (error) {
// Silently ignore - renderer is not available
// This can happen during GPU crashes, window closing, or app shutdown
logger.debug(`Failed to send IPC message to renderer: ${channel}`, 'IPC', {
error: String(error),
});
}
};
}
/** Type for the safeSend function */
export type SafeSendFn = ReturnType<typeof createSafeSend>;

View File

@@ -0,0 +1,390 @@
/**
* Web server factory for creating and configuring the web server.
* Extracted from main/index.ts for better modularity.
*/
import { BrowserWindow, ipcMain } from 'electron';
import { WebServer } from '../web-server';
import { getThemeById } from '../themes';
import { getHistoryManager } from '../history-manager';
import { logger } from '../utils/logger';
import type { ProcessManager } from '../process-manager';
/** Store interface for settings */
interface SettingsStore {
get<T>(key: string, defaultValue?: T): T;
}
/** Store interface for sessions */
interface SessionsStore {
get<T>(key: string, defaultValue?: T): T;
}
/** Store interface for groups */
interface GroupsStore {
get<T>(key: string, defaultValue?: T): T;
}
/** Dependencies required for creating the web server */
export interface WebServerFactoryDependencies {
/** Settings store for reading web interface configuration */
settingsStore: SettingsStore;
/** Sessions store for reading session data */
sessionsStore: SessionsStore;
/** Groups store for reading group data */
groupsStore: GroupsStore;
/** Function to get the main window reference */
getMainWindow: () => BrowserWindow | null;
/** Function to get the process manager reference */
getProcessManager: () => ProcessManager | null;
}
/**
* Creates a factory function for creating web servers with the given dependencies.
* This allows dependency injection and makes the code more testable.
*/
export function createWebServerFactory(deps: WebServerFactoryDependencies) {
const { settingsStore, sessionsStore, groupsStore, getMainWindow, getProcessManager } = deps;
/**
* Create and configure the web server with all necessary callbacks.
* Called when user enables the web interface.
*/
return function createWebServer(): WebServer {
// Use custom port if enabled, otherwise 0 for random port assignment
const useCustomPort = settingsStore.get('webInterfaceUseCustomPort', false);
const customPort = settingsStore.get('webInterfaceCustomPort', 8080);
const port = useCustomPort ? customPort : 0;
const server = new WebServer(port); // Custom or random port with auto-generated security token
// Set up callback for web server to fetch sessions list
server.setGetSessionsCallback(() => {
const sessions = sessionsStore.get('sessions', []) as any[];
const groups = groupsStore.get('groups', []) as any[];
return sessions.map((s: any) => {
// Find the group for this session
const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null;
// Extract last AI response for mobile preview (first 3 lines, max 500 chars)
// Use active tab's logs as the source of truth
let lastResponse = null;
const activeTab = s.aiTabs?.find((t: any) => t.id === s.activeTabId) || s.aiTabs?.[0];
const tabLogs = activeTab?.logs || [];
if (tabLogs.length > 0) {
// Find the last stdout/stderr entry from the AI (not user messages)
// Note: 'thinking' logs are already excluded since they have a distinct source type
const lastAiLog = [...tabLogs]
.reverse()
.find((log: any) => log.source === 'stdout' || log.source === 'stderr');
if (lastAiLog && lastAiLog.text) {
const fullText = lastAiLog.text;
// Get first 3 lines or 500 chars, whichever is shorter
const lines = fullText.split('\n').slice(0, 3);
let previewText = lines.join('\n');
if (previewText.length > 500) {
previewText = previewText.slice(0, 497) + '...';
} else if (fullText.length > previewText.length) {
previewText = previewText + '...';
}
lastResponse = {
text: previewText,
timestamp: lastAiLog.timestamp,
source: lastAiLog.source,
fullLength: fullText.length,
};
}
}
// Map aiTabs to web-safe format (strip logs to reduce payload)
const aiTabs =
s.aiTabs?.map((tab: any) => ({
id: tab.id,
agentSessionId: tab.agentSessionId || null,
name: tab.name || null,
starred: tab.starred || false,
inputValue: tab.inputValue || '',
usageStats: tab.usageStats || null,
createdAt: tab.createdAt,
state: tab.state || 'idle',
thinkingStartTime: tab.thinkingStartTime || null,
})) || [];
return {
id: s.id,
name: s.name,
toolType: s.toolType,
state: s.state,
inputMode: s.inputMode,
cwd: s.cwd,
groupId: s.groupId || null,
groupName: group?.name || null,
groupEmoji: group?.emoji || null,
usageStats: s.usageStats || null,
lastResponse,
agentSessionId: s.agentSessionId || null,
thinkingStartTime: s.thinkingStartTime || null,
aiTabs,
activeTabId: s.activeTabId || (aiTabs.length > 0 ? aiTabs[0].id : undefined),
bookmarked: s.bookmarked || false,
// Worktree subagent support
parentSessionId: s.parentSessionId || null,
worktreeBranch: s.worktreeBranch || null,
};
});
});
// Set up callback for web server to fetch single session details
// Optional tabId param allows fetching logs for a specific tab (avoids race conditions)
server.setGetSessionDetailCallback((sessionId: string, tabId?: string) => {
const sessions = sessionsStore.get('sessions', []) as any[];
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) return null;
// Get the requested tab's logs (or active tab if no tabId provided)
// Tabs are the source of truth for AI conversation history
// Filter out thinking and tool logs - these should never be shown on the web interface
let aiLogs: any[] = [];
const targetTabId = tabId || session.activeTabId;
if (session.aiTabs && session.aiTabs.length > 0) {
const targetTab =
session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0];
const rawLogs = targetTab?.logs || [];
// Web interface should never show thinking/tool logs regardless of desktop settings
aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool');
}
return {
id: session.id,
name: session.name,
toolType: session.toolType,
state: session.state,
inputMode: session.inputMode,
cwd: session.cwd,
aiLogs,
shellLogs: session.shellLogs || [],
usageStats: session.usageStats,
agentSessionId: session.agentSessionId,
isGitRepo: session.isGitRepo,
activeTabId: targetTabId,
};
});
// Set up callback for web server to fetch current theme
server.setGetThemeCallback(() => {
const themeId = settingsStore.get('activeThemeId', 'dracula');
return getThemeById(themeId);
});
// Set up callback for web server to fetch custom AI commands
server.setGetCustomCommandsCallback(() => {
const customCommands = settingsStore.get('customAICommands', []) as Array<{
id: string;
command: string;
description: string;
prompt: string;
}>;
return customCommands;
});
// Set up callback for web server to fetch history entries
// Uses HistoryManager for per-session storage
server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => {
const historyManager = getHistoryManager();
if (sessionId) {
// Get entries for specific session
const entries = historyManager.getEntries(sessionId);
// Sort by timestamp descending
entries.sort((a, b) => b.timestamp - a.timestamp);
return entries;
}
if (projectPath) {
// Get all entries for sessions in this project
return historyManager.getEntriesByProjectPath(projectPath);
}
// Return all entries (for global view)
return historyManager.getAllEntries();
});
// Set up callback for web server to write commands to sessions
// Note: Process IDs have -ai or -terminal suffix based on session's inputMode
server.setWriteToSessionCallback((sessionId: string, data: string) => {
const processManager = getProcessManager();
if (!processManager) {
logger.warn('processManager is null for writeToSession', 'WebServer');
return false;
}
// Get the session's current inputMode to determine which process to write to
const sessions = sessionsStore.get('sessions', []) as any[];
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer');
return false;
}
// Append -ai or -terminal suffix based on inputMode
const targetSessionId =
session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`;
logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer');
const result = processManager.write(targetSessionId, data);
logger.debug(`Write result: ${result}`, 'WebServer');
return result;
});
// Set up callback for web server to execute commands through the desktop
// This forwards AI commands to the renderer, ensuring single source of truth
// The renderer handles all spawn logic, state management, and broadcasts
server.setExecuteCommandCallback(
async (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => {
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for executeCommand', 'WebServer');
return false;
}
// Look up the session to get Claude session ID for logging
const sessions = sessionsStore.get('sessions', []) as any[];
const session = sessions.find((s: any) => s.id === sessionId);
const agentSessionId = session?.agentSessionId || 'none';
// Forward to renderer - it will handle spawn, state, and everything else
// This ensures web commands go through exact same code path as desktop commands
// Pass inputMode so renderer uses the web's intended mode (avoids sync issues)
logger.info(
`[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${agentSessionId} | Mode: ${inputMode || 'auto'} | Command: ${command.substring(0, 100)}`,
'WebServer'
);
mainWindow.webContents.send('remote:executeCommand', sessionId, command, inputMode);
return true;
}
);
// Set up callback for web server to interrupt sessions through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setInterruptSessionCallback(async (sessionId: string) => {
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for interrupt', 'WebServer');
return false;
}
// Forward to renderer - it will handle interrupt, state update, and broadcasts
// This ensures web interrupts go through exact same code path as desktop interrupts
logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer');
mainWindow.webContents.send('remote:interrupt', sessionId);
return true;
});
// Set up callback for web server to switch session mode through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => {
logger.info(
`[Web→Desktop] Mode switch callback invoked: session=${sessionId}, mode=${mode}`,
'WebServer'
);
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for switchMode', 'WebServer');
return false;
}
// Forward to renderer - it will handle mode switch and broadcasts
// This ensures web mode switches go through exact same code path as desktop
logger.info(`[Web→Desktop] Sending IPC remote:switchMode to renderer`, 'WebServer');
mainWindow.webContents.send('remote:switchMode', sessionId, mode);
return true;
});
// Set up callback for web server to select/switch to a session in the desktop
// This forwards to the renderer which handles state updates and broadcasts
// If tabId is provided, also switches to that tab within the session
server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => {
logger.info(
`[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`,
'WebServer'
);
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for selectSession', 'WebServer');
return false;
}
// Forward to renderer - it will handle session selection and broadcasts
logger.info(`[Web→Desktop] Sending IPC remote:selectSession to renderer`, 'WebServer');
mainWindow.webContents.send('remote:selectSession', sessionId, tabId);
return true;
});
// Tab operation callbacks
server.setSelectTabCallback(async (sessionId: string, tabId: string) => {
logger.info(
`[Web→Desktop] Tab select callback invoked: session=${sessionId}, tab=${tabId}`,
'WebServer'
);
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for selectTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:selectTab', sessionId, tabId);
return true;
});
server.setNewTabCallback(async (sessionId: string) => {
logger.info(`[Web→Desktop] New tab callback invoked: session=${sessionId}`, 'WebServer');
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for newTab', 'WebServer');
return null;
}
// Use invoke for synchronous response with tab ID
return new Promise((resolve) => {
const responseChannel = `remote:newTab:response:${Date.now()}`;
ipcMain.once(responseChannel, (_event, result) => {
resolve(result);
});
mainWindow.webContents.send('remote:newTab', sessionId, responseChannel);
// Timeout after 5 seconds
setTimeout(() => resolve(null), 5000);
});
});
server.setCloseTabCallback(async (sessionId: string, tabId: string) => {
logger.info(
`[Web→Desktop] Close tab callback invoked: session=${sessionId}, tab=${tabId}`,
'WebServer'
);
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for closeTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:closeTab', sessionId, tabId);
return true;
});
server.setRenameTabCallback(async (sessionId: string, tabId: string, newName: string) => {
logger.info(
`[Web→Desktop] Rename tab callback invoked: session=${sessionId}, tab=${tabId}, newName=${newName}`,
'WebServer'
);
const mainWindow = getMainWindow();
if (!mainWindow) {
logger.warn('mainWindow is null for renameTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:renameTab', sessionId, tabId, newName);
return true;
});
return server;
};
}