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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 72 additions & 35 deletions src/__tests__/main/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,30 @@ import {
} from '../../main/constants';

describe('main/constants', () => {
// groupChatId is ALWAYS a uuidv4() in production (see group-chat-storage.ts).
// The regexes now anchor on the UUID format to eliminate greedy-backtrack
// ambiguity when participant names contain sentinel substrings like
// "-participant-". Fixtures use real UUIDs accordingly.
const GC_ID = '550e8400-e29b-41d4-a716-446655440000';
const GC_ID_2 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';

describe('REGEX_MODERATOR_SESSION', () => {
it('should match moderator session IDs', () => {
const match = 'group-chat-abc123-moderator-1702934567890'.match(REGEX_MODERATOR_SESSION);
const match = `group-chat-${GC_ID}-moderator-1702934567890`.match(REGEX_MODERATOR_SESSION);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
});

it('should match moderator synthesis session IDs', () => {
const match = 'group-chat-abc123-moderator-synthesis-1702934567890'.match(
const match = `group-chat-${GC_ID}-moderator-synthesis-1702934567890`.match(
REGEX_MODERATOR_SESSION
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
});

it('should not match participant session IDs', () => {
const match = 'group-chat-abc123-participant-Claude-1702934567890'.match(
const match = `group-chat-${GC_ID}-participant-Claude-1702934567890`.match(
REGEX_MODERATOR_SESSION
);
expect(match).toBeNull();
Expand All @@ -45,121 +52,151 @@ describe('main/constants', () => {
const match = 'session-abc123'.match(REGEX_MODERATOR_SESSION);
expect(match).toBeNull();
});

it('should not match non-UUID groupChatId (strict UUID anchor)', () => {
const match = 'group-chat-abc123-moderator-1702934567890'.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(
const match = `group-chat-${GC_ID}-moderator-1702934567890`.match(
REGEX_MODERATOR_SESSION_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
});

it('should not match moderator synthesis session IDs', () => {
// This pattern expects only digits after "moderator-"
const match = 'group-chat-abc123-moderator-synthesis-1702934567890'.match(
const match = `group-chat-${GC_ID}-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);
const match = `group-chat-${GC_ID}-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
);
const match = `group-chat-${GC_ID}-participant-Claude-${GC_ID_2}`.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
expect(match![2]).toBe('Claude');
expect(match![3]).toBe('550e8400-e29b-41d4-a716-446655440000');
expect(match![3]).toBe(GC_ID_2);
});

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
);
const match = `group-chat-${GC_ID}-participant-OpenCode-Ollama-${GC_ID_2}`.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
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
);
const match = `group-chat-${GC_ID}-participant-Claude-${GC_ID_2.toUpperCase()}`.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(
const match = `group-chat-${GC_ID}-participant-Claude-1702934567890`.match(
REGEX_PARTICIPANT_UUID
);
expect(match).toBeNull();
});

it('should resist name containing "-participant-" sentinel', () => {
// Adversarial: a participant name that literally contains "-participant-"
// used to mis-parse under the old greedy (.+) capture. With the UUID
// anchor on groupChatId the split is unambiguous.
const match = `group-chat-${GC_ID}-participant-evil-participant-name-${GC_ID_2}`.match(
REGEX_PARTICIPANT_UUID
);
expect(match).not.toBeNull();
expect(match![1]).toBe(GC_ID);
expect(match![2]).toBe('evil-participant-name');
});
});

describe('REGEX_PARTICIPANT_TIMESTAMP', () => {
it('should match participant session IDs with timestamp suffix', () => {
const match = 'group-chat-abc123-participant-Claude-1702934567890'.match(
const match = `group-chat-${GC_ID}-participant-Claude-1702934567890`.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
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(
const match = `group-chat-${GC_ID}-participant-OpenCode-Ollama-1702934567890`.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
expect(match![2]).toBe('OpenCode-Ollama');
});

it('should require at least 13 digits for timestamp', () => {
const shortTimestamp = 'group-chat-abc123-participant-Claude-170293456'.match(
const shortTimestamp = `group-chat-${GC_ID}-participant-Claude-170293456`.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(shortTimestamp).toBeNull();

const longTimestamp = 'group-chat-abc123-participant-Claude-17029345678901'.match(
const longTimestamp = `group-chat-${GC_ID}-participant-Claude-17029345678901`.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(longTimestamp).not.toBeNull();
});

it('should resist name containing "-participant-" sentinel', () => {
const match = `group-chat-${GC_ID}-participant-evil-participant-name-1702934567890`.match(
REGEX_PARTICIPANT_TIMESTAMP
);
expect(match).not.toBeNull();
expect(match![1]).toBe(GC_ID);
expect(match![2]).toBe('evil-participant-name');
});
});

describe('REGEX_PARTICIPANT_FALLBACK', () => {
it('should match basic participant session IDs', () => {
const match = 'group-chat-abc123-participant-Claude-anything'.match(
const match = `group-chat-${GC_ID}-participant-Claude-anything`.match(
REGEX_PARTICIPANT_FALLBACK
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
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(
const match = `group-chat-${GC_ID}-participant-OpenCode-Ollama-1702934567890`.match(
REGEX_PARTICIPANT_FALLBACK
);
expect(match).not.toBeNull();
expect(match![1]).toBe('abc123');
expect(match![1]).toBe(GC_ID);
expect(match![2]).toBe('OpenCode'); // Only captures up to first hyphen
});

it('should not match non-UUID groupChatId', () => {
const match = 'group-chat-abc123-participant-Claude-anything'.match(
REGEX_PARTICIPANT_FALLBACK
);
expect(match).toBeNull();
});
});

describe('REGEX_AI_SUFFIX', () => {
Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/main/cue/cue-completion-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,71 @@ describe('CueEngine completion chains', () => {

engine.stop();
});

// Regression: the exit-listener production path calls
// notifyAgentCompleted with ONLY { status, exitCode } — no stdout.
// sourceOutput MUST become the empty string in that case. Any fallback
// that pulls from a session-level output store or group-chat buffer
// would leak whatever that buffer happens to contain into the
// downstream {{CUE_SOURCE_OUTPUT}} template, which is the suspected
// root cause of the "group chat bled into cue pipeline output" bug.
// Do not add a stdout fallback without updating this test.
it('produces empty sourceOutput when completionData has no stdout (exit-listener path)', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'on-done',
event: 'agent.completed',
enabled: true,
prompt: 'follow up',
source_session: 'agent-a',
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

vi.clearAllMocks();
// Exit-listener shape: only status + exitCode, no stdout.
engine.notifyAgentCompleted('agent-a', { status: 'completed', exitCode: 0 });

const request = (deps.onCueRun as ReturnType<typeof vi.fn>).mock.calls[0][0];
const event = request.event as CueEvent;
expect(event.payload.sourceOutput).toBe('');
expect(event.payload.outputTruncated).toBe(false);

engine.stop();
});

it('produces empty sourceOutput when completionData is omitted entirely', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'on-done',
event: 'agent.completed',
enabled: true,
prompt: 'follow up',
source_session: 'agent-a',
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

vi.clearAllMocks();
engine.notifyAgentCompleted('agent-a');

const request = (deps.onCueRun as ReturnType<typeof vi.fn>).mock.calls[0][0];
const event = request.event as CueEvent;
expect(event.payload.sourceOutput).toBe('');
expect(event.payload.outputTruncated).toBe(false);

engine.stop();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe('session name matching', () => {
Expand Down Expand Up @@ -501,6 +566,57 @@ describe('CueEngine completion chains', () => {

engine.stop();
});

// Regression: the user's yaml can list the same session under both its
// name and its ID ("Agent A" + "agent-a"). Pre-fix, sources.length would
// count 2 but the tracker (keyed by sessionId) would only hold 1 entry,
// so fan-in would wait forever for a "second" source that is really the
// same session. The dedupe pass in cue-fan-in-tracker resolves both
// strings to the same canonical sessionId and only counts it once.
it('dedupes fan-in sources when the same session is referenced by both name and ID', () => {
const sessions = [
createMockSession({ id: 'session-1', name: 'Owner', projectRoot: '/proj' }),
createMockSession({ id: 'agent-a', name: 'Agent A' }),
createMockSession({ id: 'agent-b', name: 'Agent B' }),
];
const config = createMockConfig({
subscriptions: [
{
name: 'all-done',
event: 'agent.completed',
enabled: true,
prompt: 'aggregate',
// "Agent A" (name) and "agent-a" (id) resolve to the same session.
source_session: ['Agent A', 'agent-a', 'Agent B'],
},
],
});
mockLoadCueConfig.mockImplementation((root) => (root === '/proj' ? config : null));
const deps = createMockDeps({ getSessions: vi.fn(() => sessions) });
const engine = new CueEngine(deps);
engine.start();

vi.clearAllMocks();

// Only two distinct session completions — but the YAML lists three
// entries. Fan-in must fire once the two unique sources are in.
engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: 'output-a' });
engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'output-b' });

expect(deps.onCueRun).toHaveBeenCalledTimes(1);
expect(deps.onCueRun).toHaveBeenCalledWith(
expect.objectContaining({
prompt: 'aggregate',
event: expect.objectContaining({
payload: expect.objectContaining({
sourceOutput: expect.stringContaining('output-a'),
}),
}),
})
);

engine.stop();
});
});

describe('fan-in timeout', () => {
Expand Down
Loading