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
244 changes: 243 additions & 1 deletion apps/mcp-server/src/mcp/handlers/execution-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { evaluateExecutionGate, type ExecutionGateInput } from './execution-gate';
import {
evaluateExecutionGate,
suppressDispatchWhileGated,
type ExecutionGateInput,
type ExecutionGate,
type GatedResponseFields,
} from './execution-gate';
import type { ClarificationMetadata } from './clarification-gate';
import type { PlanningStageMetadata } from './planning-stage';

Expand Down Expand Up @@ -273,4 +279,240 @@ describe('execution-gate', () => {
});
});
});

// ====================================================================
// suppressDispatchWhileGated (#1422)
// ====================================================================

describe('suppressDispatchWhileGated', () => {
const sampleDispatchReady = {
primaryAgent: {
name: 'software-engineer',
displayName: 'Software Engineer',
description: 'Software Engineer - PLAN mode',
dispatchParams: {
subagent_type: 'general-purpose' as const,
prompt: 'You are a software engineer...',
description: 'Software Engineer - PLAN mode',
},
},
parallelAgents: [
{
name: 'security-specialist',
displayName: 'Security Specialist',
description: 'Security specialist',
dispatchParams: {
subagent_type: 'general-purpose' as const,
prompt: 'You are a security specialist...',
description: 'Security specialist',
run_in_background: true as const,
},
},
{
name: 'performance-specialist',
displayName: 'Performance Specialist',
description: 'Performance specialist',
dispatchParams: {
subagent_type: 'general-purpose' as const,
prompt: 'You are a performance specialist...',
description: 'Performance specialist',
run_in_background: true as const,
},
},
],
};

const sampleParallelRecommendation = {
specialists: ['security-specialist', 'performance-specialist'],
hint: 'Dispatch specialists for review',
dispatch: 'auto' as const,
suggestedStack: 'review',
stackBased: true,
};

// ------------------------------------------------------------------
// Gated: suppression
// ------------------------------------------------------------------

describe('when gated=true', () => {
const gatedGate: ExecutionGate = {
gated: true,
reason: 'Request is ambiguous',
unblockCondition: 'Resolve clarification',
deferredSpecialists: ['security-specialist', 'performance-specialist'],
};

it('removes dispatchReady entirely', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.dispatchReady).toBeUndefined();
});

it('downgrades parallelAgentsRecommendation.dispatch to "deferred"', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: { ...sampleParallelRecommendation, dispatch: 'auto' },
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
});

it('preserves specialist names in parallelAgentsRecommendation for transparency', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.parallelAgentsRecommendation?.specialists).toEqual([
'security-specialist',
'performance-specialist',
]);
});

it('preserves hint in parallelAgentsRecommendation', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.parallelAgentsRecommendation?.hint).toBe('Dispatch specialists for review');
});

it('removes executionPlan when gated', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
executionPlan: { strategy: 'subagent', layers: [] },
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.executionPlan).toBeUndefined();
});

it('handles missing dispatchReady gracefully', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.dispatchReady).toBeUndefined();
expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
});

it('handles missing parallelAgentsRecommendation gracefully', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.dispatchReady).toBeUndefined();
expect(result.parallelAgentsRecommendation).toBeUndefined();
});

it('handles all fields missing gracefully', () => {
const fields: GatedResponseFields = {};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.dispatchReady).toBeUndefined();
expect(result.parallelAgentsRecommendation).toBeUndefined();
expect(result.executionPlan).toBeUndefined();
});

it('does not mutate the original fields object', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
parallelAgentsRecommendation: { ...sampleParallelRecommendation },
};
const originalDispatch = fields.parallelAgentsRecommendation?.dispatch;

suppressDispatchWhileGated(gatedGate, fields);

// Original should be untouched
expect(fields.dispatchReady).toBe(sampleDispatchReady);
expect(fields.parallelAgentsRecommendation?.dispatch).toBe(originalDispatch);
});

it('downgrades "recommend" dispatch to "deferred"', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: { ...sampleParallelRecommendation, dispatch: 'recommend' },
};

const result = suppressDispatchWhileGated(gatedGate, fields);

expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
});
});

// ------------------------------------------------------------------
// Ungated: pass-through
// ------------------------------------------------------------------

describe('when gated=false', () => {
const ungatedGate: ExecutionGate = {
gated: false,
reason: 'Request is clear',
};

it('preserves dispatchReady unchanged', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(ungatedGate, fields);

expect(result.dispatchReady).toBe(sampleDispatchReady);
});

it('preserves parallelAgentsRecommendation unchanged', () => {
const fields: GatedResponseFields = {
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(ungatedGate, fields);

expect(result.parallelAgentsRecommendation).toBe(sampleParallelRecommendation);
});

it('preserves executionPlan unchanged', () => {
const executionPlan = { strategy: 'subagent', layers: [] };
const fields: GatedResponseFields = {
executionPlan,
};

const result = suppressDispatchWhileGated(ungatedGate, fields);

expect(result.executionPlan).toBe(executionPlan);
});
});

// ------------------------------------------------------------------
// Edge: undefined gate
// ------------------------------------------------------------------

describe('when gate is undefined', () => {
it('passes through all fields unchanged', () => {
const fields: GatedResponseFields = {
dispatchReady: sampleDispatchReady,
parallelAgentsRecommendation: sampleParallelRecommendation,
};

const result = suppressDispatchWhileGated(undefined, fields);

expect(result.dispatchReady).toBe(sampleDispatchReady);
expect(result.parallelAgentsRecommendation).toBe(sampleParallelRecommendation);
});
});
});
});
70 changes: 70 additions & 0 deletions apps/mcp-server/src/mcp/handlers/execution-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,76 @@ export function evaluateExecutionGate(input: ExecutionGateInput): ExecutionGate
};
}

// ---------------------------------------------------------------------------
// Response suppression (#1422)
// ---------------------------------------------------------------------------

/** Minimal parallel-agent recommendation shape for gating. */
interface GatedParallelRecommendation {
specialists: string[];
hint: string;
dispatch?: string;
suggestedStack?: string;
stackBased?: boolean;
}

/**
* Fields from the parse_mode response that may need suppression while gated.
* Uses a generic for dispatchReady and executionPlan so the caller can pass
* the concrete types from keyword.types without coupling this module to them.
*/
export interface GatedResponseFields<D = unknown, E = unknown> {
dispatchReady?: D;
parallelAgentsRecommendation?: GatedParallelRecommendation;
executionPlan?: E;
}

/** Return type mirrors the input but with suppressed fields. */
export type SuppressedResponseFields<D = unknown, E = unknown> = GatedResponseFields<D, E>;

/**
* Suppress or downgrade dispatch-ready metadata when the execution gate
* is active (#1422).
*
* When `gate.gated === true`:
* - `dispatchReady` is removed (no Task-tool-ready params leak).
* - `executionPlan` is removed (no execution plan metadata leaks).
* - `parallelAgentsRecommendation.dispatch` is downgraded to `"deferred"`.
* - Specialist names and hint are preserved for transparency.
*
* When `gate` is undefined or ungated, all fields pass through unchanged.
*
* This function is pure and does NOT mutate its inputs.
*/
export function suppressDispatchWhileGated<D, E>(
gate: ExecutionGate | undefined,
fields: GatedResponseFields<D, E>,
): SuppressedResponseFields<D, E> {
// No gate or ungated → pass through
if (!gate || !gate.gated) {
return fields;
}

// Gated → suppress expensive dispatch metadata
const result: SuppressedResponseFields<D, E> = {};

// dispatchReady: fully removed (no dispatch params should leak)
// result.dispatchReady stays undefined

// executionPlan: fully removed
// result.executionPlan stays undefined

// parallelAgentsRecommendation: keep specialist names, downgrade dispatch
if (fields.parallelAgentsRecommendation) {
result.parallelAgentsRecommendation = {
...fields.parallelAgentsRecommendation,
dispatch: 'deferred',
};
}

return result;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading