Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
109 changes: 108 additions & 1 deletion src/__tests__/main/cue/cue-completion-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ describe('CueEngine completion chains', () => {
engine.stop();
});

it('truncates sourceOutput to 5000 chars', () => {
it('truncates sourceOutput to 5000 chars and sets outputTruncated to true', () => {
const config = createMockConfig({
subscriptions: [
{
Expand All @@ -180,6 +180,34 @@ describe('CueEngine completion chains', () => {
const request = (deps.onCueRun as ReturnType<typeof vi.fn>).mock.calls[0][0];
const event = request.event as CueEvent;
expect((event.payload.sourceOutput as string).length).toBe(5000);
expect(event.payload.outputTruncated).toBe(true);

engine.stop();
});

it('sets outputTruncated to false when output is under limit', () => {
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', { stdout: 'short output' });

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

engine.stop();
});
Expand Down Expand Up @@ -451,6 +479,42 @@ describe('CueEngine completion chains', () => {
engine.stop();
});

it('sets outputTruncated in fan-in aggregate event when any source is truncated', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'all-done',
event: 'agent.completed',
enabled: true,
prompt: 'aggregate',
source_session: ['agent-a', 'agent-b'],
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

vi.clearAllMocks();

const longOutput = 'x'.repeat(10000);
engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: longOutput });
engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'short' });

expect(deps.onCueRun).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
payload: expect.objectContaining({
outputTruncated: true,
}),
}),
})
);

engine.stop();
});

it('logs waiting message during fan-in', () => {
const config = createMockConfig({
subscriptions: [
Expand Down Expand Up @@ -565,6 +629,49 @@ describe('CueEngine completion chains', () => {

engine.stop();
});

it('includes outputTruncated in continue-mode timeout payload', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'all-done',
event: 'agent.completed',
enabled: true,
prompt: 'aggregate',
source_session: ['agent-a', 'agent-b'],
},
],
settings: {
timeout_minutes: 1,
timeout_on_fail: 'continue',
max_concurrent: 1,
queue_size: 10,
},
});
mockLoadCueConfig.mockReturnValue(config);
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

vi.clearAllMocks();
const longOutput = 'x'.repeat(10000);
engine.notifyAgentCompleted('agent-a', { stdout: longOutput });

vi.advanceTimersByTime(1 * 60 * 1000 + 100);

expect(deps.onCueRun).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
payload: expect.objectContaining({
outputTruncated: true,
partial: true,
}),
}),
})
);

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

describe('hasCompletionSubscribers', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Integration tests for pipeline chain output variable substitution.
*
* Verifies that the full flow works end-to-end:
* pipelineToYamlSubscriptions → {{CUE_SOURCE_OUTPUT}} injection → substituteTemplateVariables resolution
*/

import { describe, it, expect } from 'vitest';
import { pipelineToYamlSubscriptions } from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineToYaml';
import { substituteTemplateVariables } from '../../../../../shared/templateVariables';
import type { CuePipeline } from '../../../../../shared/cue-pipeline-types';
import type { TemplateContext, TemplateSessionInfo } from '../../../../../shared/templateVariables';

function makePipeline(overrides: Partial<CuePipeline> = {}): CuePipeline {
return {
id: 'p1',
name: 'test-pipeline',
color: '#06b6d4',
nodes: [],
edges: [],
...overrides,
};
}

const stubSession: TemplateSessionInfo = {
id: 'test-session',
name: 'test-agent',
toolType: 'claude-code',
cwd: '/tmp/test',
};

describe('pipeline chain output integration', () => {
it('generated chain prompt resolves {{CUE_SOURCE_OUTPUT}} to actual output', () => {
const pipeline = makePipeline({
nodes: [
{
id: 't1',
type: 'trigger',
position: { x: 0, y: 0 },
data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*' } },
},
{
id: 'a1',
type: 'agent',
position: { x: 300, y: 0 },
data: {
sessionId: 's1',
sessionName: 'builder',
toolType: 'claude-code',
inputPrompt: 'Build the project',
},
},
{
id: 'a2',
type: 'agent',
position: { x: 600, y: 0 },
data: {
sessionId: 's2',
sessionName: 'tester',
toolType: 'claude-code',
inputPrompt: 'Review the changes',
},
},
],
edges: [
{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' },
{ id: 'e2', source: 'a1', target: 'a2', mode: 'pass' },
],
});

const subs = pipelineToYamlSubscriptions(pipeline);
const chainSub = subs.find((s) => s.event === 'agent.completed');
expect(chainSub).toBeDefined();
expect(chainSub!.prompt).toContain('{{CUE_SOURCE_OUTPUT}}');

// Simulate the engine substituting the template variable
const context: TemplateContext = {
session: stubSession,
cue: {
sourceOutput: 'Build completed successfully. 42 files compiled.',
sourceSession: 'builder',
},
};
const resolved = substituteTemplateVariables(chainSub!.prompt!, context);
expect(resolved).toContain('Build completed successfully. 42 files compiled.');
expect(resolved).toContain('Review the changes');
expect(resolved).not.toContain('{{CUE_SOURCE_OUTPUT}}');
});

it('preserves user prompt content alongside injected source output', () => {
const pipeline = makePipeline({
nodes: [
{
id: 't1',
type: 'trigger',
position: { x: 0, y: 0 },
data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*' } },
},
{
id: 'a1',
type: 'agent',
position: { x: 300, y: 0 },
data: {
sessionId: 's1',
sessionName: 'builder',
toolType: 'claude-code',
inputPrompt: 'Build',
},
},
{
id: 'a2',
type: 'agent',
position: { x: 600, y: 0 },
data: {
sessionId: 's2',
sessionName: 'reviewer',
toolType: 'claude-code',
inputPrompt: 'Review the code changes and suggest improvements',
},
},
],
edges: [
{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' },
{ id: 'e2', source: 'a1', target: 'a2', mode: 'pass' },
],
});

const subs = pipelineToYamlSubscriptions(pipeline);
const chainSub = subs.find((s) => s.event === 'agent.completed')!;

const context: TemplateContext = {
session: stubSession,
cue: {
sourceOutput: 'Diff: +5 -3 lines',
sourceSession: 'builder',
},
};
const resolved = substituteTemplateVariables(chainSub.prompt!, context);

// Both the source output AND user instructions should be present
expect(resolved).toContain('Diff: +5 -3 lines');
expect(resolved).toContain('Review the code changes and suggest improvements');
});

it('handles empty source output gracefully', () => {
const pipeline = makePipeline({
nodes: [
{
id: 't1',
type: 'trigger',
position: { x: 0, y: 0 },
data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*' } },
},
{
id: 'a1',
type: 'agent',
position: { x: 300, y: 0 },
data: {
sessionId: 's1',
sessionName: 'builder',
toolType: 'claude-code',
inputPrompt: 'Build',
},
},
{
id: 'a2',
type: 'agent',
position: { x: 600, y: 0 },
data: {
sessionId: 's2',
sessionName: 'tester',
toolType: 'claude-code',
inputPrompt: 'Run tests',
},
},
],
edges: [
{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' },
{ id: 'e2', source: 'a1', target: 'a2', mode: 'pass' },
],
});

const subs = pipelineToYamlSubscriptions(pipeline);
const chainSub = subs.find((s) => s.event === 'agent.completed')!;

// Simulate empty source output
const context: TemplateContext = {
session: stubSession,
cue: {
sourceOutput: '',
sourceSession: 'builder',
},
};
const resolved = substituteTemplateVariables(chainSub.prompt!, context);
expect(resolved).not.toContain('{{CUE_SOURCE_OUTPUT}}');
expect(resolved).toContain('Run tests');
});
});
Loading