mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
231
src/__tests__/main/constants.test.ts
Normal file
231
src/__tests__/main/constants.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
250
src/__tests__/main/group-chat/output-buffer.test.ts
Normal file
250
src/__tests__/main/group-chat/output-buffer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
254
src/__tests__/main/group-chat/output-parser.test.ts
Normal file
254
src/__tests__/main/group-chat/output-parser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
src/__tests__/main/group-chat/session-parser.test.ts
Normal file
164
src/__tests__/main/group-chat/session-parser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
254
src/__tests__/main/utils/safe-send.test.ts
Normal file
254
src/__tests__/main/utils/safe-send.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
469
src/__tests__/main/web-server/web-server-factory.test.ts
Normal file
469
src/__tests__/main/web-server/web-server-factory.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
40
src/main/group-chat/output-buffer.ts
Normal file
40
src/main/group-chat/output-buffer.ts
Normal 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;
|
||||
}
|
||||
133
src/main/group-chat/output-parser.ts
Normal file
133
src/main/group-chat/output-parser.ts
Normal 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);
|
||||
}
|
||||
51
src/main/group-chat/session-parser.ts
Normal file
51
src/main/group-chat/session-parser.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
47
src/main/utils/safe-send.ts
Normal file
47
src/main/utils/safe-send.ts
Normal 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>;
|
||||
390
src/main/web-server/web-server-factory.ts
Normal file
390
src/main/web-server/web-server-factory.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user